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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user