Frontend completo (Etapas 11-18) e extensões de backend (Etapas 13 e 18)

Adiciona o frontend Next.js em frontend/ (setup, design system,
autenticação/tema, layout/navegação, área master, área tenant completa:
clientes, produtos, vendas, recebimentos, whatsapp, e-mail, alertas,
equipe e perfil). Estende o backend de forma aditiva e autorizada:
cookie httpOnly para refresh token (Etapa 13) e CRUD de equipe +
edição de perfil do tenant (Etapa 18, módulo src/modules/equipe/ novo
e PUT /tenant/perfil). Detalhes completos em Etapas/V2/*.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 09:41:13 -03:00
co-authored by Claude Sonnet 5
parent f9738e54da
commit 323912748f
179 changed files with 29312 additions and 22 deletions
@@ -0,0 +1,66 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
interface ConfirmDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
loading?: boolean;
/** Desabilita o botão de confirmar (ex: falta escolher uma opção obrigatória em `children`). */
confirmDisabled?: boolean;
onConfirm: () => void;
/** Conteúdo extra entre a descrição e os botões (ex: um `Select` obrigatório) — opcional. */
children?: ReactNode;
}
/**
* Confirmação explícita antes de ações sensíveis (mudar status de tenant,
* excluir plano, disparar geração de faturas etc) — Etapa 15. `children`
* é opcional (Etapa 17: usado para pedir a forma de pagamento antes de
* marcar parcelas selecionadas como pagas em lote).
*/
export function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel = 'Confirmar',
cancelLabel = 'Cancelar',
destructive = false,
loading = false,
confirmDisabled = false,
onConfirm,
children,
}: ConfirmDialogProps) {
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title={title}
description={description}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={loading}>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'destructive' : 'primary'}
loading={loading}
disabled={confirmDisabled}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</>
}
>
{children ?? <div className="sr-only" />}
</ResponsiveDialog>
);
}
@@ -0,0 +1,92 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createCustomer, type Customer } from '@/lib/api/customers';
import { customerSchema, type CustomerFormValues } from '@/lib/validators/customers';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
interface CustomerQuickCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: (customer: Customer) => void;
}
/**
* Cadastro rápido de cliente sem sair do fluxo de nova venda (Etapa 17,
* critério de aceite "não perder o que já foi preenchido na venda") — só
* os campos mínimos; o resto pode ser completado depois em
* `/app/clientes/:id`.
*/
export function CustomerQuickCreateDialog({ open, onOpenChange, onCreated }: CustomerQuickCreateDialogProps) {
const queryClient = useQueryClient();
const form = useForm<CustomerFormValues>({
resolver: zodResolver(customerSchema),
defaultValues: { razao_social: '', documento: '', email: '', telefone_whatsapp: '' },
});
const mutation = useMutation({
mutationFn: (values: CustomerFormValues) =>
createCustomer({
razao_social: values.razao_social,
documento: values.documento || undefined,
email: values.email || undefined,
telefone_whatsapp: values.telefone_whatsapp || undefined,
}),
onSuccess: (customer) => {
queryClient.invalidateQueries({ queryKey: ['tenant', 'customers'] });
toast.success('Cliente cadastrado.');
form.reset({ razao_social: '', documento: '', email: '', telefone_whatsapp: '' });
onCreated(customer);
onOpenChange(false);
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível cadastrar o cliente.');
},
});
return (
<ResponsiveDialog
open={open}
onOpenChange={onOpenChange}
title="Cadastro rápido de cliente"
description="Só o essencial — complete o resto depois no cadastro do cliente."
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancelar
</Button>
<Button loading={mutation.isPending} onClick={form.handleSubmit((values) => mutation.mutate(values))}>
Cadastrar e usar
</Button>
</>
}
>
<form
className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0"
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<FormField label="Razão social" error={form.formState.errors.razao_social?.message} required>
<Input {...form.register('razao_social')} />
</FormField>
<FormField label="Documento (CPF/CNPJ)" error={form.formState.errors.documento?.message}>
<Input {...form.register('documento')} />
</FormField>
<FormField label="Telefone/WhatsApp" error={form.formState.errors.telefone_whatsapp?.message}>
<Input {...form.register('telefone_whatsapp')} />
</FormField>
<FormField label="E-mail" error={form.formState.errors.email?.message}>
<Input type="email" {...form.register('email')} />
</FormField>
</form>
</ResponsiveDialog>
);
}
@@ -0,0 +1,81 @@
'use client';
import * as React from 'react';
import { useMutation } from '@tanstack/react-query';
import { marcarRecebimentoPago, type FormaPagamento } from '@/lib/api/sale-payments';
import { ApiError } from '@/lib/api/client';
import { formatCurrency } from '@/lib/format';
import { toast } from '@/lib/toast';
import { Button } from '@/components/ui/button';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const FORMAS: { value: FormaPagamento; label: string }[] = [
{ value: 'pix', label: 'Pix' },
{ value: 'boleto', label: 'Boleto' },
{ value: 'cartao', label: 'Cartão' },
{ value: 'dinheiro', label: 'Dinheiro' },
];
interface MarcarPagoDialogProps {
parcela: { id: string; valor: string } | null;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
/** Reaproveitado no detalhe da venda (aba Parcelas) e em `/app/recebimentos`. */
export function MarcarPagoDialog({ parcela, onOpenChange, onSuccess }: MarcarPagoDialogProps) {
const [forma, setForma] = React.useState<FormaPagamento | ''>('');
React.useEffect(() => {
setForma('');
}, [parcela?.id]);
const mutation = useMutation({
mutationFn: () => marcarRecebimentoPago(parcela!.id, forma as FormaPagamento),
onSuccess: () => {
toast.success('Parcela marcada como paga.');
onOpenChange(false);
onSuccess();
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível marcar a parcela como paga.');
},
});
return (
<ResponsiveDialog
open={parcela !== null}
onOpenChange={onOpenChange}
title="Marcar parcela como paga"
description={parcela ? `Valor: ${formatCurrency(parcela.valor)}` : undefined}
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
Cancelar
</Button>
<Button disabled={!forma} loading={mutation.isPending} onClick={() => mutation.mutate()}>
Confirmar pagamento
</Button>
</>
}
>
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<Select value={forma} onValueChange={(value) => setForma(value as FormaPagamento)}>
<SelectTrigger className="w-full" aria-label="Forma de pagamento">
<SelectValue placeholder="Selecione a forma de pagamento" />
</SelectTrigger>
<SelectContent>
{FORMAS.map((f) => (
<SelectItem key={f.value} value={f.value}>
{f.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ResponsiveDialog>
);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { productSchema, type ProductFormValues } from '@/lib/validators/products';
import type { ItemTipo } from '@/lib/api/products';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
interface ProductFormProps {
defaultValues: ProductFormValues;
onSubmit: (values: ProductFormValues) => void;
onCancel: () => void;
submitting: boolean;
submitLabel: string;
}
/** Campos de produto/serviço, compartilhado entre `/app/produtos/novo` e `/app/produtos/:id` (Etapa 16). */
export function ProductForm({ defaultValues, onSubmit, onCancel, submitting, submitLabel }: ProductFormProps) {
const form = useForm<ProductFormValues>({ resolver: zodResolver(productSchema), defaultValues });
return (
<form className="flex max-w-2xl flex-col gap-6" onSubmit={form.handleSubmit(onSubmit)}>
<Card>
<CardHeader>
<CardTitle>Dados do item</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[160px_1fr]">
<FormField label="Tipo" id="tipo" error={form.formState.errors.tipo?.message} required>
<Select
value={form.watch('tipo')}
onValueChange={(value) => form.setValue('tipo', value as ItemTipo, { shouldValidate: true })}
>
<SelectTrigger id="tipo" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="produto">Produto</SelectItem>
<SelectItem value="servico">Serviço</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="Nome" error={form.formState.errors.nome?.message} required>
<Input {...form.register('nome')} />
</FormField>
</div>
<FormField label="Descrição" error={form.formState.errors.descricao?.message}>
<Textarea rows={2} {...form.register('descricao')} />
</FormField>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="SKU" helpText="Opcional — precisa ser único no seu catálogo" error={form.formState.errors.sku?.message}>
<Input {...form.register('sku')} />
</FormField>
<FormField label="Unidade" helpText="Ex: un, kg, hora" error={form.formState.errors.unidade?.message}>
<Input {...form.register('unidade')} />
</FormField>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<FormField label="Valor de venda (R$)" error={form.formState.errors.valor_venda?.message} required>
<Input
type="number"
step="0.01"
min="0"
{...form.register('valor_venda', { valueAsNumber: true })}
/>
</FormField>
<FormField label="Valor de custo (R$)" helpText="Opcional" error={form.formState.errors.valor_custo?.message}>
<Input
type="number"
step="0.01"
min="0"
{...form.register('valor_custo', {
setValueAs: (v) => (v === '' ? undefined : Number(v)),
})}
/>
</FormField>
</div>
<FormField label="Recorrente" id="recorrente" error={form.formState.errors.recorrente?.message}>
<Select
value={form.watch('recorrente') ? 'true' : 'false'}
onValueChange={(value) => form.setValue('recorrente', value === 'true', { shouldValidate: true })}
>
<SelectTrigger id="recorrente" className="w-full sm:w-48">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="false">Não</SelectItem>
<SelectItem value="true">Sim — cobrança recorrente</SelectItem>
</SelectContent>
</Select>
</FormField>
</CardContent>
</Card>
<div className="flex items-center gap-3">
<Button type="submit" loading={submitting}>
{submitLabel}
</Button>
<Button type="button" variant="outline" onClick={onCancel}>
Cancelar
</Button>
</div>
</form>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { LucideIcon } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
interface StatCardProps {
label: string;
value: string;
icon?: LucideIcon;
loading?: boolean;
tone?: 'default' | 'success' | 'warning' | 'danger';
className?: string;
}
const TONE_CLASSNAMES: Record<NonNullable<StatCardProps['tone']>, string> = {
default: 'text-foreground',
success: 'text-success',
warning: 'text-warning',
danger: 'text-danger',
};
/** Card de resumo numérico (dashboards) — Etapa 15. */
export function StatCard({ label, value, icon: Icon, loading, tone = 'default', className }: StatCardProps) {
return (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-sm font-medium text-muted-foreground">{label}</CardTitle>
{Icon && <Icon className="size-4 text-muted-foreground" aria-hidden />}
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-8 w-24" />
) : (
<p className={cn('text-2xl font-semibold tabular-nums', TONE_CLASSNAMES[tone])}>{value}</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,90 @@
'use client';
import * as React from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateTenantStatus, type TenantStatusTransition } from '@/lib/api/tenants';
import { ApiError } from '@/lib/api/client';
import { toast } from '@/lib/toast';
import { STATUS_PILL_MAP } from '@/components/ui/status-pill';
import { Button } from '@/components/ui/button';
import { ResponsiveDialog } from '@/components/ui/responsive-dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
const TRANSITIONS: TenantStatusTransition[] = ['ativo', 'inadimplente', 'suspenso', 'cancelado'];
interface TenantStatusDialogProps {
tenant: { id: string; razao_social: string; status: string } | null;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
}
/**
* Mudança de status de tenant com confirmação explícita (critério de
* aceite da Etapa 15) — reaproveitado pela listagem e pelo detalhe de
* tenant, já que a mesma ação aparece nos dois lugares.
*/
export function TenantStatusDialog({ tenant, onOpenChange, onSuccess }: TenantStatusDialogProps) {
const queryClient = useQueryClient();
const [target, setTarget] = React.useState<TenantStatusTransition | ''>('');
React.useEffect(() => {
setTarget('');
}, [tenant?.id]);
const mutation = useMutation({
mutationFn: (status: TenantStatusTransition) => updateTenantStatus(tenant!.id, status),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['master', 'tenants'] });
toast.success('Status do tenant atualizado.');
onOpenChange(false);
onSuccess?.();
},
onError: (err) => {
toast.error(err instanceof ApiError ? err.message : 'Não foi possível atualizar o status.');
},
});
return (
<ResponsiveDialog
open={tenant !== null}
onOpenChange={onOpenChange}
title={`Mudar status — ${tenant?.razao_social ?? ''}`}
description="Suspender ou cancelar bloqueia imediatamente o login de todos os usuários deste tenant, mesmo com sessões já abertas."
footer={
<>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={mutation.isPending}>
Cancelar
</Button>
<Button
variant={target === 'suspenso' || target === 'cancelado' ? 'destructive' : 'primary'}
disabled={!target}
loading={mutation.isPending}
onClick={() => target && mutation.mutate(target)}
>
Confirmar
</Button>
</>
}
>
<div className="flex flex-col gap-4 px-4 pb-4 sm:px-0 sm:pb-0">
<p className="text-sm text-muted-foreground">
Status atual: <span className="font-medium text-foreground">{STATUS_PILL_MAP[tenant?.status as keyof typeof STATUS_PILL_MAP]?.label ?? tenant?.status}</span>
</p>
<Select value={target} onValueChange={(v) => setTarget(v as TenantStatusTransition)}>
<SelectTrigger className="w-full" aria-label="Novo status">
<SelectValue placeholder="Selecione o novo status" />
</SelectTrigger>
<SelectContent>
{TRANSITIONS.filter((status) => status !== tenant?.status).map((status) => (
<SelectItem key={status} value={status}>
{STATUS_PILL_MAP[status].label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</ResponsiveDialog>
);
}
+497
View File
@@ -0,0 +1,497 @@
'use client';
import * as React from 'react';
import { useFieldArray, useForm, type UseFormRegisterReturn } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useQuery } from '@tanstack/react-query';
import { Plus, Trash2 } from 'lucide-react';
import { listCustomers } from '@/lib/api/customers';
import { listProducts } from '@/lib/api/products';
import type { SaleInput } from '@/lib/api/sales';
import { saleFormSchema, type SaleFormValues } from '@/lib/validators/sales';
import { formatCurrency, formatDate } from '@/lib/format';
import { CustomerQuickCreateDialog } from '@/components/domain/customer-quick-create-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Combobox } from '@/components/ui/combobox';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
function toCents(value: number): number {
return Math.round(value * 100);
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/** Calcula os vencimentos (30 em 30 dias) só para pré-visualização — o backend recalcula de verdade. */
function previewVencimentosAutomaticos(dataVenda: string, numeroParcelas: number): Date[] {
const base = dataVenda ? new Date(`${dataVenda}T00:00:00Z`) : new Date();
return Array.from({ length: numeroParcelas }, (_, i) => {
const d = new Date(base);
d.setUTCDate(d.getUTCDate() + 30 * (i + 1));
return d;
});
}
export interface VendaFormSubmitHandlers {
/** Só no modo `create` — salva como orçamento. */
onSaveDraft?: (input: SaleInput) => void;
/** No `create`, confirma a venda; no `edit`, salva as alterações (venda continua em orçamento). */
onConfirmOrSave: (input: SaleInput) => void;
}
interface VendaFormProps extends VendaFormSubmitHandlers {
mode: 'create' | 'edit';
defaultValues: SaleFormValues;
submittingDraft?: boolean;
submittingConfirmOrSave?: boolean;
onCancel: () => void;
}
export function VendaForm({
mode,
defaultValues,
submittingDraft,
submittingConfirmOrSave,
onSaveDraft,
onConfirmOrSave,
onCancel,
}: VendaFormProps) {
const [quickCreateOpen, setQuickCreateOpen] = React.useState(false);
const form = useForm<SaleFormValues>({ resolver: zodResolver(saleFormSchema), defaultValues });
const itensArray = useFieldArray({ control: form.control, name: 'itens' });
const parcelasArray = useFieldArray({ control: form.control, name: 'parcelasManuais' });
const customerId = form.watch('customer_id');
const itens = form.watch('itens');
const desconto = form.watch('desconto') || 0;
const dataVenda = form.watch('data_venda');
const modoParcelas = form.watch('modoParcelas');
const numeroParcelas = form.watch('numeroParcelas');
const parcelasManuais = form.watch('parcelasManuais') ?? [];
const { data: customersData } = useQuery({
queryKey: ['tenant', 'customers', 'combobox'],
queryFn: () => listCustomers({ ativo: true, limit: 100 }),
});
const { data: productsData } = useQuery({
queryKey: ['tenant', 'produtos', 'combobox'],
queryFn: () => listProducts({ ativo: true, limit: 100 }),
});
const customerOptions = (customersData?.data ?? []).map((c) => ({
value: c.id,
label: c.nome_fantasia || c.razao_social,
}));
const productOptions = (productsData?.data ?? []).map((p) => ({
value: p.id,
label: p.sku ? `${p.nome} (${p.sku})` : p.nome,
}));
const subtotalItens = itens.reduce((acc, item) => acc + item.quantidade * item.valor_unitario, 0);
const total = Math.max(0, subtotalItens - desconto);
const descontoExcedeItens = desconto > subtotalItens;
const somaParcelasManuais = parcelasManuais.reduce((acc, p) => acc + (p.valor || 0), 0);
const diffParcelas = toCents(total) - toCents(somaParcelasManuais);
const parcelasBatem = modoParcelas === 'automatico' || diffParcelas === 0;
function addItem(productId: string) {
const produto = productsData?.data.find((p) => p.id === productId);
if (!produto) return;
if (itensArray.fields.some((f) => f.product_service_id === productId)) return;
itensArray.append({
product_service_id: produto.id,
nome: produto.nome,
quantidade: 1,
valor_unitario: Number(produto.valor_venda),
valor_catalogo: Number(produto.valor_venda),
});
}
function buildInput(values: SaleFormValues): SaleInput | null {
if (values.modoParcelas === 'manual') {
if (!values.parcelasManuais || values.parcelasManuais.length === 0) {
form.setError('parcelasManuais', { message: 'Adicione pelo menos uma parcela' });
return null;
}
if (!parcelasBatem) {
form.setError('parcelasManuais', {
message: `A soma das parcelas não bate com o total da venda (diferença de ${formatCurrency(Math.abs(diffParcelas) / 100)})`,
});
return null;
}
} else if (!values.numeroParcelas || values.numeroParcelas < 1) {
form.setError('numeroParcelas', { message: 'Informe o número de parcelas' });
return null;
}
return {
customer_id: values.customer_id,
data_venda: values.data_venda,
desconto: values.desconto || undefined,
observacoes: values.observacoes || undefined,
itens: values.itens.map((item) => ({
product_service_id: item.product_service_id,
quantidade: item.quantidade,
valor_unitario: item.valor_unitario,
})),
...(values.modoParcelas === 'manual'
? { parcelas: values.parcelasManuais!.map((p) => ({ valor: p.valor, vencimento: p.vencimento })) }
: { numero_parcelas: values.numeroParcelas }),
};
}
const vencimentosPreview =
modoParcelas === 'automatico' && numeroParcelas
? previewVencimentosAutomaticos(dataVenda, numeroParcelas)
: [];
return (
<div className="flex flex-col gap-6 pb-24 lg:pb-0">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_380px]">
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<CardTitle>Cliente e data</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex items-end gap-2">
<FormField
label="Cliente"
id="customer_id"
className="flex-1"
error={form.formState.errors.customer_id?.message}
required
>
<Combobox
id="customer_id"
options={customerOptions}
value={customerId}
onChange={(value) => form.setValue('customer_id', value, { shouldValidate: true })}
placeholder="Selecionar cliente..."
searchPlaceholder="Buscar cliente..."
/>
</FormField>
<Button type="button" variant="outline" onClick={() => setQuickCreateOpen(true)}>
<Plus /> Novo
</Button>
</div>
<FormField label="Data da venda" error={form.formState.errors.data_venda?.message} required>
<Input type="date" {...form.register('data_venda')} />
</FormField>
<FormField label="Observações" error={form.formState.errors.observacoes?.message}>
<Textarea rows={2} {...form.register('observacoes')} />
</FormField>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle>Itens da venda</CardTitle>
<div className="w-64">
<Combobox
options={productOptions}
value=""
onChange={addItem}
placeholder="Adicionar item..."
searchPlaceholder="Buscar produto/serviço..."
/>
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{form.formState.errors.itens?.message && (
<p className="text-sm text-destructive">{form.formState.errors.itens.message}</p>
)}
{itensArray.fields.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">
Nenhum item adicionado ainda — busque um produto ou serviço acima.
</p>
) : (
<div className="flex flex-col divide-y divide-border">
{itensArray.fields.map((field, index) => {
const item = itens[index];
const alterado = item && item.valor_unitario !== item.valor_catalogo;
return (
<div key={field.id} className="flex flex-wrap items-end gap-3 py-3">
<div className="min-w-40 flex-1">
<p className="text-sm font-medium text-foreground">{field.nome}</p>
{alterado && (
<Badge variant="outline" className="mt-1">
Valor alterado do catálogo ({formatCurrency(field.valor_catalogo)})
</Badge>
)}
</div>
<FormField label="Qtd." className="w-24">
<Input
type="number"
step="0.01"
min="0.01"
{...form.register(`itens.${index}.quantidade`, { valueAsNumber: true })}
/>
</FormField>
<FormField label="Valor unit. (R$)" className="w-32">
<Input
type="number"
step="0.01"
min="0"
{...form.register(`itens.${index}.valor_unitario`, { valueAsNumber: true })}
/>
</FormField>
<div className="w-28 text-right">
<p className="text-xs text-muted-foreground">Subtotal</p>
<p className="font-medium tabular-nums text-foreground">
{formatCurrency((item?.quantidade || 0) * (item?.valor_unitario || 0))}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remover item"
onClick={() => itensArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Parcelas</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Select
value={modoParcelas}
onValueChange={(value) => form.setValue('modoParcelas', value as 'automatico' | 'manual')}
>
<SelectTrigger className="w-full sm:w-64" aria-label="Modo de parcelamento">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="automatico">Gerar parcelas automaticamente</SelectItem>
<SelectItem value="manual">Definir parcelas manualmente</SelectItem>
</SelectContent>
</Select>
{modoParcelas === 'automatico' ? (
<div className="flex flex-col gap-3">
<FormField
label="Número de parcelas"
helpText="Vencimentos calculados de 30 em 30 dias a partir da data da venda"
error={form.formState.errors.numeroParcelas?.message}
className="w-40"
>
<Input
type="number"
min="1"
{...form.register('numeroParcelas', {
setValueAs: (v) => (v === '' ? undefined : Number(v)),
})}
/>
</FormField>
{vencimentosPreview.length > 0 && (
<ul className="flex flex-col gap-1 text-sm text-muted-foreground">
{vencimentosPreview.map((date, i) => (
<li key={i} className="flex justify-between tabular-nums">
<span>Parcela {i + 1}</span>
<span>{formatDate(date)}</span>
</li>
))}
</ul>
)}
</div>
) : (
<div className="flex flex-col gap-3">
{form.formState.errors.parcelasManuais?.message && (
<p className="text-sm text-destructive">{form.formState.errors.parcelasManuais.message}</p>
)}
{parcelasArray.fields.map((field, index) => (
<div key={field.id} className="flex items-end gap-2">
<FormField label={`Parcela ${index + 1} — valor`} className="flex-1">
<Input
type="number"
step="0.01"
min="0"
{...form.register(`parcelasManuais.${index}.valor`, { valueAsNumber: true })}
/>
</FormField>
<FormField label="Vencimento" className="flex-1">
<Input type="date" {...form.register(`parcelasManuais.${index}.vencimento`)} />
</FormField>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label="Remover parcela"
onClick={() => parcelasArray.remove(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
className="self-start"
onClick={() => parcelasArray.append({ valor: 0, vencimento: dataVenda || todayIso() })}
>
<Plus /> Adicionar parcela
</Button>
<div
className={`rounded-lg p-3 text-sm ${
parcelasBatem ? 'bg-success/10 text-success' : 'bg-danger/10 text-danger'
}`}
>
Soma das parcelas: {formatCurrency(somaParcelasManuais)} —{' '}
{parcelasBatem ? 'bate com o total' : `diferença de ${formatCurrency(Math.abs(diffParcelas) / 100)}`}
</div>
</div>
)}
</CardContent>
</Card>
</div>
<div className="hidden flex-col gap-4 lg:flex">
<ResumoCard
subtotalItens={subtotalItens}
desconto={desconto}
total={total}
descontoExcedeItens={descontoExcedeItens}
formErrorDesconto={form.formState.errors.desconto?.message}
registerDesconto={form.register('desconto', { valueAsNumber: true })}
mode={mode}
submittingDraft={submittingDraft}
submittingConfirmOrSave={submittingConfirmOrSave}
onSaveDraft={onSaveDraft ? () => { const input = buildInput(form.getValues()); if (input) onSaveDraft(input); } : undefined}
onConfirmOrSave={() => { const input = buildInput(form.getValues()); if (input) onConfirmOrSave(input); }}
onCancel={onCancel}
/>
</div>
</div>
{/* Mobile: resumo + ações fixos no rodapé, sempre visíveis */}
<div className="fixed inset-x-0 bottom-16 z-10 border-t border-border bg-surface p-4 shadow-lg lg:hidden">
<div className="mb-3 flex items-center justify-between">
<span className="text-sm text-muted-foreground">Total da venda</span>
<span className="text-lg font-semibold tabular-nums text-foreground">{formatCurrency(total)}</span>
</div>
<div className="flex gap-2">
{onSaveDraft && (
<Button
type="button"
variant="outline"
className="flex-1"
loading={submittingDraft}
onClick={() => {
const input = buildInput(form.getValues());
if (input) onSaveDraft(input);
}}
>
Orçamento
</Button>
)}
<Button
type="button"
className="flex-1"
loading={submittingConfirmOrSave}
onClick={() => {
const input = buildInput(form.getValues());
if (input) onConfirmOrSave(input);
}}
>
{mode === 'create' ? 'Confirmar venda' : 'Salvar alterações'}
</Button>
</div>
</div>
<CustomerQuickCreateDialog
open={quickCreateOpen}
onOpenChange={setQuickCreateOpen}
onCreated={(customer) => form.setValue('customer_id', customer.id, { shouldValidate: true })}
/>
</div>
);
}
function ResumoCard({
subtotalItens,
desconto,
total,
descontoExcedeItens,
formErrorDesconto,
registerDesconto,
mode,
submittingDraft,
submittingConfirmOrSave,
onSaveDraft,
onConfirmOrSave,
onCancel,
}: {
subtotalItens: number;
desconto: number;
total: number;
descontoExcedeItens: boolean;
formErrorDesconto?: string;
registerDesconto: UseFormRegisterReturn<'desconto'>;
mode: 'create' | 'edit';
submittingDraft?: boolean;
submittingConfirmOrSave?: boolean;
onSaveDraft?: () => void;
onConfirmOrSave: () => void;
onCancel: () => void;
}) {
return (
<Card className="sticky top-4">
<CardHeader>
<CardTitle>Resumo</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<FormField label="Desconto geral (R$)" error={formErrorDesconto || (descontoExcedeItens ? 'Desconto maior que a soma dos itens' : undefined)}>
<Input type="number" step="0.01" min="0" {...registerDesconto} />
</FormField>
<div className="flex flex-col gap-1.5 border-t border-border pt-3 text-sm">
<div className="flex justify-between text-muted-foreground">
<span>Subtotal itens</span>
<span className="tabular-nums">{formatCurrency(subtotalItens)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Desconto</span>
<span className="tabular-nums">-{formatCurrency(desconto)}</span>
</div>
<div className="flex justify-between text-base font-semibold text-foreground">
<span>Total</span>
<span className="tabular-nums">{formatCurrency(total)}</span>
</div>
</div>
<div className="flex flex-col gap-2">
{onSaveDraft && (
<Button type="button" variant="outline" loading={submittingDraft} onClick={onSaveDraft}>
Salvar como orçamento
</Button>
)}
<Button type="button" loading={submittingConfirmOrSave} onClick={onConfirmOrSave}>
{mode === 'create' ? 'Confirmar venda' : 'Salvar alterações'}
</Button>
<Button type="button" variant="ghost" onClick={onCancel}>
Cancelar
</Button>
</div>
</CardContent>
</Card>
);
}