Fix inconsistent line endings across all project files for cross-environment consistency. Add complete unit test suite covering core utilities, API routes, and server services. Add Playwright E2E test setup with smoke, auth, and agent API tests. Update all project configuration files to match standardized formatting. Fix MCP API error handling to properly propagate HTTP status codes and use correct JSON-RPC error codes. Add VSCode workspace configuration, extensions, and example MCP config files. Update .gitignore to exclude new test and build artifacts.
191 lines
6.4 KiB
TypeScript
191 lines
6.4 KiB
TypeScript
"use client";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/components/ui/table";
|
|
import { Plus, Trash2 } from "lucide-react";
|
|
import { formatCurrency } from "@/lib/pb-helpers";
|
|
|
|
interface LineItem {
|
|
id?: string;
|
|
description: string;
|
|
quantity: number;
|
|
cost_price: number;
|
|
markup?: number;
|
|
selling_price: number;
|
|
}
|
|
|
|
interface LineItemsTableProps {
|
|
items: LineItem[];
|
|
onChange: (items: LineItem[]) => void;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export function LineItemsTable({ items, onChange, readOnly = false }: LineItemsTableProps) {
|
|
const updateItem = (index: number, field: keyof LineItem, value: string | number) => {
|
|
const updated = [...items];
|
|
const item = { ...updated[index], [field]: value };
|
|
|
|
if (field === "cost_price" || field === "markup") {
|
|
const cost = field === "cost_price" ? Number(value) : item.cost_price;
|
|
const markup = field === "markup" ? Number(value) : item.markup || 0;
|
|
item.selling_price = Number((cost * (1 + markup / 100)).toFixed(2));
|
|
}
|
|
|
|
updated[index] = item;
|
|
onChange(updated);
|
|
};
|
|
|
|
const addItem = () => {
|
|
onChange([
|
|
...items,
|
|
{
|
|
description: "",
|
|
quantity: 1,
|
|
cost_price: 0,
|
|
markup: 0,
|
|
selling_price: 0,
|
|
},
|
|
]);
|
|
};
|
|
|
|
const removeItem = (index: number) => {
|
|
onChange(items.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const subtotal = items.reduce((sum, item) => sum + item.selling_price * item.quantity, 0);
|
|
const vat = subtotal * 0.15;
|
|
const total = subtotal + vat;
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="rounded-md border">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="min-w-[200px]">Description</TableHead>
|
|
<TableHead className="w-[80px]">Qty</TableHead>
|
|
<TableHead className="w-[120px]">Cost</TableHead>
|
|
<TableHead className="w-[100px]">Markup %</TableHead>
|
|
<TableHead className="w-[120px]">Selling</TableHead>
|
|
<TableHead className="w-[120px] text-right">Total</TableHead>
|
|
{!readOnly && <TableHead className="w-[50px]" />}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{items.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={readOnly ? 6 : 7} className="text-center py-8 text-muted-foreground">
|
|
No line items. Click "Add Item" to add one.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
items.map((item, index) => (
|
|
<TableRow key={index}>
|
|
<TableCell>
|
|
{readOnly ? (
|
|
item.description
|
|
) : (
|
|
<Input
|
|
value={item.description}
|
|
onChange={(e) => updateItem(index, "description", e.target.value)}
|
|
placeholder="Item description"
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{readOnly ? (
|
|
item.quantity
|
|
) : (
|
|
<Input
|
|
type="number"
|
|
min="1"
|
|
value={item.quantity}
|
|
onChange={(e) => updateItem(index, "quantity", Number(e.target.value))}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{readOnly ? (
|
|
formatCurrency(item.cost_price)
|
|
) : (
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={item.cost_price}
|
|
onChange={(e) => updateItem(index, "cost_price", Number(e.target.value))}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{readOnly ? (
|
|
`${item.markup || 0}%`
|
|
) : (
|
|
<Input
|
|
type="number"
|
|
min="0"
|
|
step="0.01"
|
|
value={item.markup || 0}
|
|
onChange={(e) => updateItem(index, "markup", Number(e.target.value))}
|
|
/>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{formatCurrency(item.selling_price)}
|
|
</TableCell>
|
|
<TableCell className="text-right font-medium">
|
|
{formatCurrency(item.selling_price * item.quantity)}
|
|
</TableCell>
|
|
{!readOnly && (
|
|
<TableCell>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-destructive"
|
|
onClick={() => removeItem(index)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
|
|
{!readOnly && (
|
|
<Button variant="outline" size="sm" onClick={addItem}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Item
|
|
</Button>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<div className="w-64 space-y-2">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Subtotal</span>
|
|
<span>{formatCurrency(subtotal)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">VAT (15%)</span>
|
|
<span>{formatCurrency(vat)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-base font-bold border-t pt-2">
|
|
<span>Grand Total</span>
|
|
<span>{formatCurrency(total)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|