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>
498 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|