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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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