- Documents hub signer status now renders via a label map (`Pending`, `Signed`, `Declined`, …) instead of the raw lowercase enum value. - Invoice detail formats `dueDate` and `paymentDate` as `MMM d, yyyy` via `date-fns` instead of leaking raw `2025-03-14` ISO strings, and swaps the "Payment Method" free-text input for a `Select` of labelled options (`Bank transfer`, `Credit card`, …) so we never store `bank_transfer` from a hand-typed field again. - Interest tabs `MilestoneSection` status badge uses a `humanizeStatus` helper so values like `waiting_for_signatures` show as `Waiting For Signatures` (correctly title-cased) instead of being a lower-snake-case fragment inside an ALL-CAPS pill. - `OUTCOME_BADGE` in the interest header now has a fall-through that renders any unknown outcome as a closed-state badge, preventing a closed interest from looking open just because its enum was added upstream without a matching label entry. - Interest timeline route joins the `user` table and returns `userName` alongside `userId`; the client renders the resolved name instead of a 36-char UUID. Falls back to `'a teammate'` if the user row was deleted. - Invoice "New / Step 3 — Review" replaces the truncated UUID display with a server-resolved client/company name via a small `useQuery`, so users can confirm they picked the right billing entity before submitting. - New `loading.tsx` for client detail renders a header / tab strip / card skeleton during the server-component / initial-query window that previously flashed empty. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams, useRouter, useSearchParams } 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, Wallet } from 'lucide-react';
|
|
|
|
import { useMobileChrome } from '@/components/layout/mobile/mobile-layout-provider';
|
|
|
|
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 { OwnerPicker } from '@/components/shared/owner-picker';
|
|
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 searchParams = useSearchParams();
|
|
const prefilledInterestId = searchParams.get('interestId') ?? undefined;
|
|
const prefilledKind =
|
|
searchParams.get('kind') === 'deposit' ? ('deposit' as const) : ('general' as const);
|
|
|
|
const [step, setStep] = useState(1);
|
|
|
|
const { setChrome } = useMobileChrome();
|
|
useEffect(() => {
|
|
setChrome({ title: 'New Invoice', showBackButton: true });
|
|
return () => setChrome({ title: null, showBackButton: false });
|
|
}, [setChrome]);
|
|
|
|
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
|
|
// fetch enough of the interest to display "Deposit for {client} — Berth {n}" in
|
|
// the review step. Doubles as the source of truth for the billing entity prefill.
|
|
const { data: prefilledInterest } = useQuery<{
|
|
data: {
|
|
id: string;
|
|
clientId: string;
|
|
clientName: string | null;
|
|
berthMooringNumber: string | null;
|
|
};
|
|
}>({
|
|
queryKey: ['interest-prefill', prefilledInterestId],
|
|
queryFn: () => apiFetch(`/api/v1/interests/${prefilledInterestId}`),
|
|
enabled: !!prefilledInterestId,
|
|
});
|
|
|
|
const methods = useForm<CreateInvoiceInput>({
|
|
resolver: zodResolver(createInvoiceSchema),
|
|
defaultValues: {
|
|
paymentTerms: 'net30',
|
|
currency: 'USD',
|
|
lineItems: [],
|
|
expenseIds: [],
|
|
interestId: prefilledInterestId,
|
|
kind: prefilledKind,
|
|
},
|
|
});
|
|
|
|
const {
|
|
register,
|
|
handleSubmit,
|
|
watch,
|
|
setValue,
|
|
formState: { errors },
|
|
} = methods;
|
|
|
|
const watchedValues = watch();
|
|
const isDepositInvoice = watchedValues.kind === 'deposit';
|
|
|
|
// Resolve the selected billing entity to a human name so the review step
|
|
// shows "Acme Yacht Charters" instead of "company 4f2a1b…".
|
|
const billingEntityRef = watchedValues.billingEntity ?? null;
|
|
const { data: billingEntityName } = useQuery<{ name: string }>({
|
|
queryKey: ['billing-entity-name', billingEntityRef?.type, billingEntityRef?.id],
|
|
queryFn: async () => {
|
|
if (!billingEntityRef) return { name: '' };
|
|
const path =
|
|
billingEntityRef.type === 'company'
|
|
? `/api/v1/companies/${billingEntityRef.id}`
|
|
: `/api/v1/clients/${billingEntityRef.id}`;
|
|
const res = await apiFetch<{
|
|
data: { fullName?: string; name?: string };
|
|
}>(path);
|
|
return {
|
|
name: res?.data?.fullName ?? res?.data?.name ?? '',
|
|
};
|
|
},
|
|
enabled: !!billingEntityRef?.id,
|
|
staleTime: 60_000,
|
|
});
|
|
|
|
// Pre-fill the billing entity from the linked interest's client on launch.
|
|
useEffect(() => {
|
|
if (prefilledInterest?.data && !watchedValues.billingEntity) {
|
|
setValue(
|
|
'billingEntity',
|
|
{ type: 'client', id: prefilledInterest.data.clientId },
|
|
{ shouldValidate: true },
|
|
);
|
|
}
|
|
// We only want this to run when the interest data first arrives.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [prefilledInterest?.data?.clientId]);
|
|
|
|
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<{ data?: { id?: string } }>('/api/v1/invoices', {
|
|
method: 'POST',
|
|
body: data,
|
|
}),
|
|
onSuccess: (res) => {
|
|
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([
|
|
'billingEntity',
|
|
'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 — desktop only; mobile gets the title from the topbar */}
|
|
<div className="hidden sm: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">
|
|
{isDepositInvoice ? (
|
|
<div className="flex items-start gap-3 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900">
|
|
<Wallet className="mt-0.5 h-4 w-4 shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="font-medium">Deposit invoice</p>
|
|
<p className="text-xs text-amber-800">
|
|
{prefilledInterest?.data
|
|
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
|
|
prefilledInterest.data.berthMooringNumber
|
|
? ` — Berth ${prefilledInterest.data.berthMooringNumber}`
|
|
: ''
|
|
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
|
|
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<div className="space-y-2">
|
|
<Label>
|
|
Billing entity <span className="text-destructive">*</span>
|
|
</Label>
|
|
<OwnerPicker
|
|
value={watchedValues.billingEntity ?? null}
|
|
onChange={(ref) => {
|
|
if (ref) {
|
|
setValue('billingEntity', ref, { shouldValidate: true });
|
|
}
|
|
}}
|
|
/>
|
|
{errors.billingEntity && (
|
|
<p className="text-xs text-destructive">
|
|
{errors.billingEntity.message ??
|
|
errors.billingEntity.id?.message ??
|
|
errors.billingEntity.type?.message}
|
|
</p>
|
|
)}
|
|
<p className="text-xs text-muted-foreground">
|
|
Select the client or company to invoice. Their name will be snapshotted into the
|
|
invoice.
|
|
</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 CreateInvoiceInput['paymentTerms'])
|
|
}
|
|
>
|
|
<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 { message?: string }).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">Billing Entity</span>
|
|
<p className="font-medium mt-0.5">
|
|
{watchedValues.billingEntity ? (
|
|
<>
|
|
{billingEntityName?.name ? (
|
|
<span>{billingEntityName.name}</span>
|
|
) : (
|
|
<span className="text-muted-foreground">Loading…</span>
|
|
)}{' '}
|
|
<span className="text-xs text-muted-foreground capitalize">
|
|
({watchedValues.billingEntity.type})
|
|
</span>
|
|
</>
|
|
) : (
|
|
<span className="text-muted-foreground italic">Not selected</span>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|