Files
pn-new-crm/src/app/(dashboard)/[portSlug]/invoices/new/page.tsx

389 lines
13 KiB
TypeScript
Raw Normal View History

'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useForm, FormProvider } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Check, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { InvoiceLineItems } from '@/components/invoices/invoice-line-items';
import { apiFetch } from '@/lib/api/client';
import { createInvoiceSchema, type CreateInvoiceInput } from '@/lib/validators/invoices';
const PAYMENT_TERMS = [
{ label: 'Immediate', value: 'immediate' },
{ label: 'Net 10', value: 'net10' },
{ label: 'Net 15', value: 'net15' },
{ label: 'Net 30', value: 'net30' },
{ label: 'Net 45', value: 'net45' },
{ label: 'Net 60', value: 'net60' },
];
const STEPS = [
{ id: 1, label: 'Client Info' },
{ id: 2, label: 'Line Items' },
{ id: 3, label: 'Review' },
];
export default function NewInvoicePage() {
const params = useParams<{ portSlug: string }>();
const portSlug = params?.portSlug ?? '';
const router = useRouter();
const [step, setStep] = useState(1);
const methods = useForm<CreateInvoiceInput>({
resolver: zodResolver(createInvoiceSchema),
defaultValues: {
paymentTerms: 'net30',
currency: 'USD',
lineItems: [],
expenseIds: [],
},
});
const { register, handleSubmit, watch, setValue, formState: { errors } } = methods;
const watchedValues = watch();
const lineItems = watchedValues.lineItems ?? [];
const subtotal = lineItems.reduce(
(sum, li) => sum + (Number(li.quantity) || 0) * (Number(li.unitPrice) || 0),
0,
);
const isNet10 = watchedValues.paymentTerms === 'net10';
const discountPct = isNet10 ? 2 : 0;
const discountAmount = (subtotal * discountPct) / 100;
const total = subtotal - discountAmount;
const createMutation = useMutation({
mutationFn: (data: CreateInvoiceInput) =>
apiFetch('/api/v1/invoices', {
method: 'POST',
body: data,
}),
onSuccess: (res: any) => {
const id = res?.data?.id;
if (id) {
router.push(`/${portSlug}/invoices/${id}`);
} else {
router.push(`/${portSlug}/invoices`);
}
},
});
async function goNext() {
if (step === 1) {
const valid = await methods.trigger([
'clientName',
'billingEmail',
'billingAddress',
'dueDate',
'paymentTerms',
'currency',
]);
if (valid) setStep(2);
} else if (step === 2) {
setStep(3);
}
}
function goBack() {
setStep((s) => Math.max(1, s - 1));
}
function onSubmit(data: CreateInvoiceInput) {
createMutation.mutate(data);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<Button
variant="ghost"
size="sm"
onClick={() => router.push(`/${portSlug}/invoices`)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold">New Invoice</h1>
</div>
{/* Step indicator */}
<div className="flex items-center gap-2">
{STEPS.map((s, idx) => (
<div key={s.id} className="flex items-center gap-2">
<div
className={`flex items-center justify-center w-7 h-7 rounded-full text-xs font-medium ${
step > s.id
? 'bg-primary text-primary-foreground'
: step === s.id
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{step > s.id ? <Check className="h-3.5 w-3.5" /> : s.id}
</div>
<span
className={`text-sm ${
step === s.id ? 'font-medium' : 'text-muted-foreground'
}`}
>
{s.label}
</span>
{idx < STEPS.length - 1 && (
<div className="w-8 h-px bg-border mx-1" />
)}
</div>
))}
</div>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Step 1: Client Info */}
{step === 1 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Client Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-1">
<Label htmlFor="clientName">
Client Name <span className="text-destructive">*</span>
</Label>
<Input
id="clientName"
{...register('clientName')}
placeholder="Client or company name"
/>
{errors.clientName && (
<p className="text-xs text-destructive">{errors.clientName.message}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="billingEmail">Billing Email</Label>
<Input
id="billingEmail"
type="email"
{...register('billingEmail')}
placeholder="billing@example.com"
/>
{errors.billingEmail && (
<p className="text-xs text-destructive">{errors.billingEmail.message}</p>
)}
</div>
<div className="space-y-1">
<Label htmlFor="billingAddress">Billing Address</Label>
<Textarea
id="billingAddress"
{...register('billingAddress')}
placeholder="Address"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<Label htmlFor="dueDate">
Due Date <span className="text-destructive">*</span>
</Label>
<Input
id="dueDate"
type="date"
{...register('dueDate')}
/>
{errors.dueDate && (
<p className="text-xs text-destructive">{errors.dueDate.message}</p>
)}
</div>
<div className="space-y-1">
<Label>Payment Terms</Label>
<Select
defaultValue="net30"
onValueChange={(v) => setValue('paymentTerms', v as any)}
>
<SelectTrigger>
<SelectValue placeholder="Select terms" />
</SelectTrigger>
<SelectContent>
{PAYMENT_TERMS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-1">
<Label>Currency</Label>
<Input
{...register('currency')}
placeholder="USD"
maxLength={3}
className="uppercase w-24"
/>
</div>
<div className="space-y-1">
<Label htmlFor="notes">Notes</Label>
<Textarea
id="notes"
{...register('notes')}
placeholder="Payment instructions or notes..."
rows={3}
/>
</div>
</CardContent>
</Card>
)}
{/* Step 2: Line Items */}
{step === 2 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Line Items</CardTitle>
</CardHeader>
<CardContent>
<InvoiceLineItems name="lineItems" />
{errors.lineItems && !Array.isArray(errors.lineItems) && (
<p className="text-xs text-destructive mt-2">
{(errors.lineItems as any).message}
</p>
)}
{errors.root && (
<p className="text-xs text-destructive mt-2">{errors.root.message}</p>
)}
</CardContent>
</Card>
)}
{/* Step 3: Review */}
{step === 3 && (
<Card>
<CardHeader>
<CardTitle className="text-base">Review & Create</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Client</span>
<p className="font-medium mt-0.5">{watchedValues.clientName}</p>
</div>
<div>
<span className="text-muted-foreground">Due Date</span>
<p className="font-medium mt-0.5">{watchedValues.dueDate}</p>
</div>
<div>
<span className="text-muted-foreground">Payment Terms</span>
<p className="font-medium mt-0.5 capitalize">
{watchedValues.paymentTerms}
</p>
</div>
<div>
<span className="text-muted-foreground">Currency</span>
<p className="font-medium mt-0.5">{watchedValues.currency}</p>
</div>
</div>
{lineItems.length > 0 && (
<div className="border rounded-md p-3 space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Line Items
</p>
{lineItems.map((li, i) => (
<div key={i} className="flex justify-between text-sm">
<span>{li.description}</span>
<span className="tabular-nums">
{(Number(li.quantity) * Number(li.unitPrice)).toFixed(2)}{' '}
{watchedValues.currency}
</span>
</div>
))}
</div>
)}
<div className="border rounded-md p-3 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Subtotal</span>
<span className="tabular-nums">
{subtotal.toFixed(2)} {watchedValues.currency}
</span>
</div>
{isNet10 && (
<div className="flex justify-between text-green-600">
<span>Net 10 Discount (~2%)</span>
<span className="tabular-nums">
-{discountAmount.toFixed(2)} {watchedValues.currency}
</span>
</div>
)}
<div className="flex justify-between font-semibold border-t pt-2 mt-1">
<span>Total</span>
<span className="tabular-nums">
{total.toFixed(2)} {watchedValues.currency}
</span>
</div>
</div>
{createMutation.isError && (
<p className="text-sm text-destructive">
Failed to create invoice. Please try again.
</p>
)}
</CardContent>
</Card>
)}
{/* Navigation */}
<div className="flex items-center justify-between">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={step === 1}
>
<ChevronLeft className="mr-1.5 h-4 w-4" />
Back
</Button>
{step < 3 ? (
<Button type="button" onClick={goNext}>
Next
<ChevronRight className="ml-1.5 h-4 w-4" />
</Button>
) : (
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
) : (
<Check className="mr-1.5 h-4 w-4" />
)}
Create Invoice
</Button>
)}
</div>
</form>
</FormProvider>
</div>
);
}