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