Files
SeConectai_Vendas/frontend/components/domain/venda-form.tsx
T
tiagoandClaude Sonnet 5 323912748f 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>
2026-08-16 09:41:13 -03:00

498 lines
20 KiB
TypeScript

'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>
);
}