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>
117 lines
4.7 KiB
TypeScript
117 lines
4.7 KiB
TypeScript
'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>
|
|
);
|
|
}
|