feat: CSV exports, PDF improvements, play internal/external with hour split, roles cleanup, admin companies with 'Cliente avulso', ticket list spacing/alignment fixes, status translations and mappings
This commit is contained in:
parent
addd4ce6e8
commit
3bafcc5a0a
45 changed files with 1401 additions and 256 deletions
213
src/components/admin/companies/admin-companies-manager.tsx
Normal file
213
src/components/admin/companies/admin-companies-manager.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
"use client"
|
||||
|
||||
import { useMemo, useState, useTransition } from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
type Company = {
|
||||
id: string
|
||||
tenantId: string
|
||||
name: string
|
||||
slug: string
|
||||
isAvulso: boolean
|
||||
cnpj: string | null
|
||||
domain: string | null
|
||||
phone: string | null
|
||||
description: string | null
|
||||
address: string | null
|
||||
}
|
||||
|
||||
export function AdminCompaniesManager({ initialCompanies }: { initialCompanies: Company[] }) {
|
||||
const [companies, setCompanies] = useState<Company[]>(initialCompanies)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [form, setForm] = useState<Partial<Company>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const resetForm = () => setForm({})
|
||||
|
||||
async function refresh() {
|
||||
const r = await fetch("/api/admin/companies", { credentials: "include" })
|
||||
const json = (await r.json()) as { companies: Company[] }
|
||||
setCompanies(json.companies)
|
||||
}
|
||||
|
||||
function handleEdit(c: Company) {
|
||||
setEditingId(c.id)
|
||||
setForm({ ...c })
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
const payload = {
|
||||
name: form.name?.trim(),
|
||||
slug: form.slug?.trim(),
|
||||
isAvulso: Boolean(form.isAvulso ?? false),
|
||||
cnpj: form.cnpj?.trim() || null,
|
||||
domain: form.domain?.trim() || null,
|
||||
phone: form.phone?.trim() || null,
|
||||
description: form.description?.trim() || null,
|
||||
address: form.address?.trim() || null,
|
||||
}
|
||||
if (!payload.name || !payload.slug) {
|
||||
toast.error("Informe nome e slug válidos")
|
||||
return
|
||||
}
|
||||
startTransition(async () => {
|
||||
toast.loading(editingId ? "Atualizando empresa..." : "Criando empresa...", { id: "companies" })
|
||||
try {
|
||||
if (editingId) {
|
||||
const r = await fetch(`/api/admin/companies/${editingId}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("update_failed")
|
||||
} else {
|
||||
const r = await fetch(`/api/admin/companies`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("create_failed")
|
||||
}
|
||||
await refresh()
|
||||
resetForm()
|
||||
setEditingId(null)
|
||||
toast.success(editingId ? "Empresa atualizada" : "Empresa criada", { id: "companies" })
|
||||
} catch {
|
||||
toast.error("Não foi possível salvar", { id: "companies" })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function toggleAvulso(c: Company) {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/admin/companies/${c.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ isAvulso: !c.isAvulso }),
|
||||
credentials: "include",
|
||||
})
|
||||
if (!r.ok) throw new Error("toggle_failed")
|
||||
await refresh()
|
||||
} catch {
|
||||
toast.error("Não foi possível atualizar o cliente avulso")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Nova empresa</CardTitle>
|
||||
<CardDescription>Cadastre um cliente/empresa e defina se é avulso.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nome</Label>
|
||||
<Input value={form.name ?? ""} onChange={(e) => setForm((p) => ({ ...p, name: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Slug</Label>
|
||||
<Input value={form.slug ?? ""} onChange={(e) => setForm((p) => ({ ...p, slug: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>CNPJ</Label>
|
||||
<Input value={form.cnpj ?? ""} onChange={(e) => setForm((p) => ({ ...p, cnpj: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Domínio</Label>
|
||||
<Input value={form.domain ?? ""} onChange={(e) => setForm((p) => ({ ...p, domain: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Telefone</Label>
|
||||
<Input value={form.phone ?? ""} onChange={(e) => setForm((p) => ({ ...p, phone: e.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Endereço</Label>
|
||||
<Input value={form.address ?? ""} onChange={(e) => setForm((p) => ({ ...p, address: e.target.value }))} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:col-span-2">
|
||||
<Checkbox
|
||||
checked={Boolean(form.isAvulso ?? false)}
|
||||
onCheckedChange={(v) => setForm((p) => ({ ...p, isAvulso: Boolean(v) }))}
|
||||
id="is-avulso"
|
||||
/>
|
||||
<Label htmlFor="is-avulso">Cliente avulso?</Label>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button type="submit" disabled={isPending}>{editingId ? "Salvar alterações" : "Cadastrar empresa"}</Button>
|
||||
{editingId ? (
|
||||
<Button type="button" variant="ghost" className="ml-2" onClick={() => { resetForm(); setEditingId(null) }}>
|
||||
Cancelar
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-slate-200">
|
||||
<CardHeader>
|
||||
<CardTitle>Empresas cadastradas</CardTitle>
|
||||
<CardDescription>Gerencie empresas e o status de cliente avulso.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Nome</TableHead>
|
||||
<TableHead>Slug</TableHead>
|
||||
<TableHead>Avulso</TableHead>
|
||||
<TableHead>Domínio</TableHead>
|
||||
<TableHead>Telefone</TableHead>
|
||||
<TableHead>CNPJ</TableHead>
|
||||
<TableHead>Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{companies.map((c) => (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="font-medium">{c.name}</TableCell>
|
||||
<TableCell>{c.slug}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => void toggleAvulso(c)}>
|
||||
{c.isAvulso ? "Sim" : "Não"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>{c.domain ?? "—"}</TableCell>
|
||||
<TableCell>{c.phone ?? "—"}</TableCell>
|
||||
<TableCell>{c.cnpj ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => handleEdit(c)}>
|
||||
Editar
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue