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>
2026-03-26 11:52:51 +01:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useEffect } from 'react';
|
|
|
|
|
import { useForm } from 'react-hook-form';
|
|
|
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
|
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { 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 {
|
|
|
|
|
Sheet,
|
|
|
|
|
SheetContent,
|
|
|
|
|
SheetHeader,
|
|
|
|
|
SheetTitle,
|
|
|
|
|
SheetFooter,
|
|
|
|
|
} from '@/components/ui/sheet';
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
|
|
|
import { createExpenseSchema, type CreateExpenseInput } from '@/lib/validators/expenses';
|
|
|
|
|
import { EXPENSE_CATEGORIES, PAYMENT_METHODS } from '@/lib/constants';
|
|
|
|
|
import type { ExpenseRow } from './expense-columns';
|
|
|
|
|
|
|
|
|
|
interface ExpenseFormDialogProps {
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
expense?: ExpenseRow;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ExpenseFormDialog({ open, onOpenChange, expense }: ExpenseFormDialogProps) {
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const isEdit = !!expense;
|
|
|
|
|
|
|
|
|
|
const {
|
|
|
|
|
register,
|
|
|
|
|
handleSubmit,
|
|
|
|
|
setValue,
|
|
|
|
|
reset,
|
|
|
|
|
formState: { errors, isSubmitting },
|
|
|
|
|
} = useForm<CreateExpenseInput>({
|
|
|
|
|
resolver: zodResolver(createExpenseSchema),
|
|
|
|
|
defaultValues: {
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
paymentStatus: 'unpaid',
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (open && expense) {
|
|
|
|
|
reset({
|
|
|
|
|
establishmentName: expense.establishmentName ?? undefined,
|
|
|
|
|
amount: Number(expense.amount),
|
|
|
|
|
currency: expense.currency,
|
2026-03-26 12:06:18 +01:00
|
|
|
category: expense.category as string,
|
|
|
|
|
paymentMethod: expense.paymentMethod as string,
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
payer: expense.payer ?? undefined,
|
|
|
|
|
expenseDate: new Date(expense.expenseDate),
|
2026-03-26 12:06:18 +01:00
|
|
|
paymentStatus: (expense.paymentStatus as string) ?? 'unpaid',
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
} else if (open && !expense) {
|
|
|
|
|
reset({
|
|
|
|
|
currency: 'USD',
|
|
|
|
|
paymentStatus: 'unpaid',
|
|
|
|
|
expenseDate: new Date(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [open, expense, reset]);
|
|
|
|
|
|
|
|
|
|
const mutation = useMutation({
|
|
|
|
|
mutationFn: (data: CreateExpenseInput) => {
|
|
|
|
|
if (isEdit) {
|
|
|
|
|
return apiFetch(`/api/v1/expenses/${expense.id}`, {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
body: data,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
return apiFetch('/api/v1/expenses', { method: 'POST', body: data });
|
|
|
|
|
},
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['expenses'] });
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function onSubmit(data: CreateExpenseInput) {
|
|
|
|
|
mutation.mutate(data);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<SheetContent className="w-full sm:max-w-lg overflow-y-auto">
|
|
|
|
|
<SheetHeader>
|
|
|
|
|
<SheetTitle>{isEdit ? 'Edit Expense' : 'New Expense'}</SheetTitle>
|
|
|
|
|
</SheetHeader>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 mt-4">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="expenseDate">Date *</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="expenseDate"
|
|
|
|
|
type="date"
|
|
|
|
|
{...register('expenseDate', {
|
|
|
|
|
setValueAs: (v) => (v ? new Date(v) : undefined),
|
|
|
|
|
})}
|
|
|
|
|
defaultValue={expense?.expenseDate
|
|
|
|
|
? new Date(expense.expenseDate).toISOString().split('T')[0]
|
|
|
|
|
: new Date().toISOString().split('T')[0]}
|
|
|
|
|
/>
|
|
|
|
|
{errors.expenseDate && (
|
|
|
|
|
<p className="text-xs text-destructive">{errors.expenseDate.message}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="amount">Amount *</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="amount"
|
|
|
|
|
type="number"
|
|
|
|
|
step="0.01"
|
|
|
|
|
min="0"
|
|
|
|
|
placeholder="0.00"
|
|
|
|
|
{...register('amount', { valueAsNumber: true })}
|
|
|
|
|
/>
|
|
|
|
|
{errors.amount && (
|
|
|
|
|
<p className="text-xs text-destructive">{errors.amount.message}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="currency">Currency</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="currency"
|
|
|
|
|
placeholder="USD"
|
|
|
|
|
maxLength={3}
|
|
|
|
|
{...register('currency')}
|
|
|
|
|
/>
|
|
|
|
|
{errors.currency && (
|
|
|
|
|
<p className="text-xs text-destructive">{errors.currency.message}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="establishmentName">Establishment</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="establishmentName"
|
|
|
|
|
placeholder="Establishment name"
|
|
|
|
|
{...register('establishmentName')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="category">Category</Label>
|
|
|
|
|
<Select
|
2026-03-26 12:06:18 +01:00
|
|
|
onValueChange={(v) => setValue('category', v as string)}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
defaultValue={expense?.category ?? undefined}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger id="category">
|
|
|
|
|
<SelectValue placeholder="Select category" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{EXPENSE_CATEGORIES.map((cat) => (
|
|
|
|
|
<SelectItem key={cat} value={cat}>
|
|
|
|
|
{cat.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="paymentMethod">Payment Method</Label>
|
|
|
|
|
<Select
|
2026-03-26 12:06:18 +01:00
|
|
|
onValueChange={(v) => setValue('paymentMethod', v as string)}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
defaultValue={expense?.paymentMethod ?? undefined}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger id="paymentMethod">
|
|
|
|
|
<SelectValue placeholder="Select method" />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{PAYMENT_METHODS.map((m) => (
|
|
|
|
|
<SelectItem key={m} value={m}>
|
|
|
|
|
{m.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="payer">Payer</Label>
|
|
|
|
|
<Input
|
|
|
|
|
id="payer"
|
|
|
|
|
placeholder="Who paid?"
|
|
|
|
|
{...register('payer')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="paymentStatus">Payment Status</Label>
|
|
|
|
|
<Select
|
2026-03-26 12:06:18 +01:00
|
|
|
onValueChange={(v) => setValue('paymentStatus', v as 'unpaid' | 'paid' | 'partial')}
|
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>
2026-03-26 11:52:51 +01:00
|
|
|
defaultValue={expense?.paymentStatus ?? 'unpaid'}
|
|
|
|
|
>
|
|
|
|
|
<SelectTrigger id="paymentStatus">
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
<SelectItem value="unpaid">Unpaid</SelectItem>
|
|
|
|
|
<SelectItem value="paid">Paid</SelectItem>
|
|
|
|
|
<SelectItem value="partial">Partial</SelectItem>
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
<Label htmlFor="description">Description</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="description"
|
|
|
|
|
placeholder="Additional notes..."
|
|
|
|
|
rows={3}
|
|
|
|
|
{...register('description')}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{mutation.isError && (
|
|
|
|
|
<p className="text-sm text-destructive">
|
|
|
|
|
{(mutation.error as Error).message}
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<SheetFooter className="pt-2">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => onOpenChange(false)}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button type="submit" disabled={isSubmitting || mutation.isPending}>
|
|
|
|
|
{(isSubmitting || mutation.isPending) && (
|
|
|
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
|
|
|
)}
|
|
|
|
|
{isEdit ? 'Save Changes' : 'Create Expense'}
|
|
|
|
|
</Button>
|
|
|
|
|
</SheetFooter>
|
|
|
|
|
</form>
|
|
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
);
|
|
|
|
|
}
|