Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
15
src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx
Normal file
15
src/app/(dashboard)/[portSlug]/invoices/[id]/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { use } from 'react';
|
||||
import { InvoiceDetail } from '@/components/invoices/invoice-detail';
|
||||
|
||||
interface InvoiceDetailPageProps {
|
||||
params: Promise<{ portSlug: string; id: string }>;
|
||||
}
|
||||
|
||||
export default function InvoiceDetailPage({ params }: InvoiceDetailPageProps) {
|
||||
const { id } = use(params);
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-6">
|
||||
<InvoiceDetail invoiceId={id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
388
src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
Normal file
388
src/app/(dashboard)/[portSlug]/invoices/new/page.tsx
Normal file
@@ -0,0 +1,388 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
190
src/app/(dashboard)/[portSlug]/invoices/page.tsx
Normal file
190
src/app/(dashboard)/[portSlug]/invoices/page.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DataTable } from '@/components/shared/data-table';
|
||||
import { FilterBar } from '@/components/shared/filter-bar';
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { EmptyState } from '@/components/shared/empty-state';
|
||||
import { TableSkeleton } from '@/components/shared/loading-skeleton';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { invoiceFilterDefinitions } from '@/components/invoices/invoice-filters';
|
||||
import { getInvoiceColumns, type InvoiceRow } from '@/components/invoices/invoice-columns';
|
||||
import { usePaginatedQuery } from '@/hooks/use-paginated-query';
|
||||
import { useRealtimeInvalidation } from '@/hooks/use-realtime-invalidation';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ label: 'All', value: '' },
|
||||
{ label: 'Draft', value: 'draft' },
|
||||
{ label: 'Sent', value: 'sent' },
|
||||
{ label: 'Paid', value: 'paid' },
|
||||
{ label: 'Overdue', value: 'overdue' },
|
||||
] as const;
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
const portSlug = params?.portSlug ?? '';
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [deleteTarget, setDeleteTarget] = useState<InvoiceRow | null>(null);
|
||||
|
||||
const {
|
||||
data,
|
||||
pagination,
|
||||
isLoading,
|
||||
isFetching,
|
||||
sort,
|
||||
setSort,
|
||||
setPage,
|
||||
setPageSize,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
} = usePaginatedQuery<InvoiceRow>({
|
||||
queryKey: ['invoices'],
|
||||
endpoint: '/api/v1/invoices',
|
||||
filterDefinitions: invoiceFilterDefinitions,
|
||||
});
|
||||
|
||||
const activeStatus = (filters.status as string) ?? '';
|
||||
|
||||
useRealtimeInvalidation({
|
||||
'invoice:created': [['invoices']],
|
||||
'invoice:updated': [['invoices']],
|
||||
'invoice:sent': [['invoices']],
|
||||
'invoice:paid': [['invoices']],
|
||||
'invoice:overdue': [['invoices']],
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/invoices/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
setDeleteTarget(null);
|
||||
},
|
||||
});
|
||||
|
||||
const sendMutation = useMutation({
|
||||
mutationFn: (id: string) =>
|
||||
apiFetch(`/api/v1/invoices/${id}/send`, { method: 'POST' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['invoices'] });
|
||||
},
|
||||
});
|
||||
|
||||
const columns = getInvoiceColumns({
|
||||
portSlug,
|
||||
onSend: (invoice) => sendMutation.mutate(invoice.id),
|
||||
onRecordPayment: (invoice) =>
|
||||
router.push(`/${portSlug}/invoices/${invoice.id}?tab=payment`),
|
||||
onDelete: (invoice) => setDeleteTarget(invoice),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<PageHeader
|
||||
title="Invoices"
|
||||
description="Create and manage port invoices"
|
||||
actions={
|
||||
<PermissionGate resource="invoices" action="create">
|
||||
<Button size="sm" onClick={() => router.push(`/${portSlug}/invoices/new`)}>
|
||||
<Plus className="mr-1.5 h-4 w-4" />
|
||||
New Invoice
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Status quick-filter tabs */}
|
||||
<div className="flex items-center gap-1 border-b">
|
||||
{STATUS_TABS.map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
onClick={() => setFilter('status', tab.value || undefined)}
|
||||
className={`px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors ${
|
||||
activeStatus === tab.value
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
filters={invoiceFilterDefinitions.filter((f) => f.key !== 'status')}
|
||||
values={filters}
|
||||
onChange={setFilter}
|
||||
onClear={clearFilters}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<TableSkeleton />
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={data}
|
||||
pagination={pagination}
|
||||
onPaginationChange={(p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
}}
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
isLoading={isFetching && !isLoading}
|
||||
getRowId={(row) => row.id}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
title="No invoices found"
|
||||
description="Get started by creating your first invoice."
|
||||
action={{
|
||||
label: 'New Invoice',
|
||||
onClick: () => router.push(`/${portSlug}/invoices/new`),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete confirmation */}
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 bg-background/80 backdrop-blur-sm z-50 flex items-center justify-center">
|
||||
<div className="bg-background border rounded-lg shadow-lg p-6 max-w-sm w-full space-y-4">
|
||||
<h3 className="font-semibold">Delete Invoice?</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will permanently delete invoice{' '}
|
||||
<span className="font-mono font-medium">{deleteTarget.invoiceNumber}</span>.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => deleteMutation.mutate(deleteTarget.id)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 className="mr-1.5 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user