feat(line-items): separate name (mandatory, searchable) from description (optional)
- Add POLineItem.name column; migrate existing description→name; description is now optional - NameCell component: name input with fuzzy product search, description input stacked below - Read-only view shows name prominently, description in subdued text below - All server actions (create, edit, manager edit, import) updated to read/write name - ParsedImportLine.description renamed to .name throughout import parser and form - Seed data updated; CLAUDE.md added Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
2b5e125260
commit
f95b3279c8
22 changed files with 155 additions and 101 deletions
|
|
@ -11,7 +11,8 @@ import type { Vessel, Account, Vendor, PurchaseOrder } from "@prisma/client";
|
|||
type SerializedLineItem = {
|
||||
id: string;
|
||||
poId: string;
|
||||
description: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
size: string | null;
|
||||
|
|
@ -59,7 +60,8 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
|
||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||
po.lineItems.map((li) => ({
|
||||
description: li.description,
|
||||
name: li.name,
|
||||
description: li.description ?? undefined,
|
||||
quantity: li.quantity,
|
||||
unit: li.unit,
|
||||
size: li.size ?? undefined,
|
||||
|
|
@ -79,7 +81,8 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
const form = document.getElementById("mgr-edit-po-form") as HTMLFormElement;
|
||||
const data = new FormData(form);
|
||||
lineItems.forEach((item, i) => {
|
||||
data.set(`lineItems[${i}].description`, item.description);
|
||||
data.set(`lineItems[${i}].name`, item.name);
|
||||
data.set(`lineItems[${i}].description`, item.description ?? "");
|
||||
data.set(`lineItems[${i}].quantity`, String(item.quantity));
|
||||
data.set(`lineItems[${i}].unit`, item.unit);
|
||||
data.set(`lineItems[${i}].size`, item.size ?? "");
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { z } from "zod";
|
|||
type ActionResult = { ok: true } | { error: string };
|
||||
|
||||
const lineItemSchema = z.object({
|
||||
description: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
quantity: z.coerce.number().positive(),
|
||||
unit: z.string().min(1),
|
||||
size: z.string().optional(),
|
||||
|
|
@ -22,7 +23,7 @@ export async function managerEditLineItems({
|
|||
lineItems,
|
||||
}: {
|
||||
poId: string;
|
||||
lineItems: Array<{ description: string; quantity: number; unit: string; size?: string; unitPrice: number }>;
|
||||
lineItems: Array<{ name: string; description?: string; quantity: number; unit: string; size?: string; unitPrice: number }>;
|
||||
}): Promise<ActionResult> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "approve_po")) {
|
||||
|
|
@ -40,7 +41,8 @@ export async function managerEditLineItems({
|
|||
if (po.status !== "MGR_REVIEW") return { error: "Line items can only be edited while the PO is under review." };
|
||||
|
||||
const originalSnapshot = po.lineItems.map((li) => ({
|
||||
description: li.description,
|
||||
name: (li as typeof li & { name: string }).name,
|
||||
description: li.description ?? undefined,
|
||||
quantity: Number(li.quantity),
|
||||
unit: li.unit,
|
||||
size: li.size ?? undefined,
|
||||
|
|
@ -59,7 +61,8 @@ export async function managerEditLineItems({
|
|||
lineItems: {
|
||||
deleteMany: {},
|
||||
create: parsed.data.map((item, idx) => ({
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
description: item.description ?? null,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
size: item.size ?? null,
|
||||
|
|
|
|||
|
|
@ -24,13 +24,14 @@ export async function managerEditPo(
|
|||
|
||||
// Parse line items from FormData
|
||||
const lineItems: Array<{
|
||||
description: string; quantity: number; unit: string;
|
||||
name: string; description?: string; quantity: number; unit: string;
|
||||
size?: string; unitPrice: number; gstRate: number;
|
||||
}> = [];
|
||||
let i = 0;
|
||||
while (formData.has(`lineItems[${i}].description`)) {
|
||||
while (formData.has(`lineItems[${i}].name`)) {
|
||||
lineItems.push({
|
||||
description: formData.get(`lineItems[${i}].description`) as string,
|
||||
name: formData.get(`lineItems[${i}].name`) as string,
|
||||
description: (formData.get(`lineItems[${i}].description`) as string) || undefined,
|
||||
quantity: Number(formData.get(`lineItems[${i}].quantity`)),
|
||||
unit: formData.get(`lineItems[${i}].unit`) as string,
|
||||
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
||||
|
|
@ -97,12 +98,13 @@ export async function managerEditPo(
|
|||
tcOthers: extPo.tcOthers,
|
||||
totalAmount: Number(po.totalAmount),
|
||||
lineItems: po.lineItems.map((li) => ({
|
||||
description: li.description,
|
||||
name: (li as typeof li & { name: string }).name,
|
||||
description: li.description ?? undefined,
|
||||
quantity: Number(li.quantity),
|
||||
unit: li.unit,
|
||||
size: li.size ?? undefined,
|
||||
unitPrice: Number(li.unitPrice),
|
||||
gstRate: Number((li as typeof li & { gstRate?: unknown }).gstRate ?? 0.18),
|
||||
gstRate: Number(li.gstRate ?? 0.18),
|
||||
})),
|
||||
};
|
||||
|
||||
|
|
@ -130,7 +132,8 @@ export async function managerEditPo(
|
|||
lineItems: {
|
||||
deleteMany: {},
|
||||
create: data.lineItems.map((item, idx) => ({
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
description: item.description ?? null,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
size: item.size ?? null,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Review Purchase Order</h1>
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ import { revalidatePath } from "next/cache";
|
|||
function parseLineItems(formData: FormData) {
|
||||
const items = [];
|
||||
let i = 0;
|
||||
while (formData.has(`lineItems[${i}].description`)) {
|
||||
while (formData.has(`lineItems[${i}].name`)) {
|
||||
items.push({
|
||||
description: formData.get(`lineItems[${i}].description`) as string,
|
||||
name: formData.get(`lineItems[${i}].name`) as string,
|
||||
description: (formData.get(`lineItems[${i}].description`) as string) || undefined,
|
||||
quantity: Number(formData.get(`lineItems[${i}].quantity`)),
|
||||
unit: formData.get(`lineItems[${i}].unit`) as string,
|
||||
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
||||
|
|
@ -100,7 +101,8 @@ export async function updatePo(
|
|||
lineItems: {
|
||||
deleteMany: {},
|
||||
create: data.lineItems.map((item, idx) => ({
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
description: item.description ?? null,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
size: item.size ?? null,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ const INPUT_CLS =
|
|||
type SerializedLineItem = {
|
||||
id: string;
|
||||
poId: string;
|
||||
description: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
size: string | null;
|
||||
|
|
@ -41,7 +42,8 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
const router = useRouter();
|
||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||
po.lineItems.map((li) => ({
|
||||
description: li.description,
|
||||
name: li.name,
|
||||
description: li.description ?? undefined,
|
||||
quantity: li.quantity,
|
||||
unit: li.unit,
|
||||
size: li.size ?? undefined,
|
||||
|
|
@ -61,7 +63,8 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
const data = new FormData(form);
|
||||
data.set("intent", intent);
|
||||
lineItems.forEach((item, i) => {
|
||||
data.set(`lineItems[${i}].description`, item.description);
|
||||
data.set(`lineItems[${i}].name`, item.name);
|
||||
data.set(`lineItems[${i}].description`, item.description ?? "");
|
||||
data.set(`lineItems[${i}].quantity`, String(item.quantity));
|
||||
data.set(`lineItems[${i}].unit`, item.unit);
|
||||
data.set(`lineItems[${i}].size`, item.size ?? "");
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export default async function EditPoPage({ params }: Props) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Edit Purchase Order</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p>
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ export default async function PoDetailPage({ params }: Props) {
|
|||
: [];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} />
|
||||
{canProvideVendorId && <VendorIdForm poId={po.id} vendors={vendors} />}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export async function importPo(
|
|||
submitterId: session.user.id,
|
||||
lineItems: {
|
||||
create: input.lineItems.map((item, idx) => ({
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unitPrice: item.unitPrice,
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
|||
const lineTotal = taxable * (1 + (li.gstRate ?? 0.18));
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-2 pr-4 text-neutral-900">{li.description}</td>
|
||||
<td className="py-2 pr-4 text-neutral-900">{li.name}</td>
|
||||
<td className="py-2 pl-4 text-right">{li.quantity}</td>
|
||||
<td className="py-2 pl-3 text-neutral-500">{li.unit}</td>
|
||||
<td className="py-2 pl-4 text-right">{formatCurrency(li.unitPrice)}</td>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default async function ImportPoPage() {
|
|||
]);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Import Purchase Order</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
|
|
|
|||
|
|
@ -23,7 +23,8 @@ export async function createPo(
|
|||
const intent = formData.get("intent") as "draft" | "submit";
|
||||
|
||||
const lineItems: Array<{
|
||||
description: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
size?: string;
|
||||
|
|
@ -32,9 +33,10 @@ export async function createPo(
|
|||
productId?: string;
|
||||
}> = [];
|
||||
let i = 0;
|
||||
while (formData.has(`lineItems[${i}].description`)) {
|
||||
while (formData.has(`lineItems[${i}].name`)) {
|
||||
lineItems.push({
|
||||
description: formData.get(`lineItems[${i}].description`) as string,
|
||||
name: formData.get(`lineItems[${i}].name`) as string,
|
||||
description: (formData.get(`lineItems[${i}].description`) as string) || undefined,
|
||||
quantity: Number(formData.get(`lineItems[${i}].quantity`)),
|
||||
unit: formData.get(`lineItems[${i}].unit`) as string,
|
||||
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
||||
|
|
@ -104,7 +106,8 @@ export async function createPo(
|
|||
submittedAt: intent === "submit" ? new Date() : null,
|
||||
lineItems: {
|
||||
create: data.lineItems.map((item, idx) => ({
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
description: item.description ?? null,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
size: item.size ?? null,
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ interface Props {
|
|||
export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
||||
const router = useRouter();
|
||||
const [lineItems, setLineItems] = useState<LineItemInput[]>([
|
||||
{ description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 },
|
||||
{ name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 },
|
||||
]);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||
|
|
@ -35,7 +35,8 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
|||
const data = new FormData(form);
|
||||
data.set("intent", intent);
|
||||
lineItems.forEach((item, i) => {
|
||||
data.set(`lineItems[${i}].description`, item.description);
|
||||
data.set(`lineItems[${i}].name`, item.name);
|
||||
data.set(`lineItems[${i}].description`, item.description ?? "");
|
||||
data.set(`lineItems[${i}].quantity`, String(item.quantity));
|
||||
data.set(`lineItems[${i}].unit`, item.unit);
|
||||
data.set(`lineItems[${i}].size`, item.size ?? "");
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export default async function NewPoPage() {
|
|||
]);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="max-w-6xl">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">New Purchase Order</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
|
|
|
|||
|
|
@ -56,7 +56,9 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
const gstRate = Number((li as { gstRate?: unknown }).gstRate ?? 0.18);
|
||||
const taxable = Number(li.totalPrice);
|
||||
const gstAmt = taxable * gstRate;
|
||||
return { sn: i + 1, desc: li.description, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
|
||||
const li_ = li as typeof li & { name?: string };
|
||||
const desc = li_.name ?? li.description ?? "";
|
||||
return { sn: i + 1, desc, unit: li.unit, qty, unitPrice, gstRate, taxable, gstAmt, total: taxable + gstAmt };
|
||||
});
|
||||
|
||||
const totalTaxable = items.reduce((s, i) => s + i.taxable, 0);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ type PoWithRelations = {
|
|||
} | null;
|
||||
lineItems: {
|
||||
id: string;
|
||||
description: string;
|
||||
name: string;
|
||||
description?: string | null;
|
||||
quantity: import("@prisma/client").Prisma.Decimal;
|
||||
unit: string;
|
||||
size?: string | null;
|
||||
|
|
@ -87,7 +88,8 @@ const ACTION_LABELS: Record<string, string> = {
|
|||
|
||||
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
|
||||
const lineItemsForEditor = po.lineItems.map((li) => ({
|
||||
description: li.description,
|
||||
name: li.name,
|
||||
description: li.description ?? undefined,
|
||||
quantity: Number(li.quantity),
|
||||
unit: li.unit,
|
||||
size: li.size ?? undefined,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ interface Props {
|
|||
}
|
||||
|
||||
type EditRow = {
|
||||
name: string;
|
||||
description: string;
|
||||
quantity: string;
|
||||
unit: string;
|
||||
|
|
@ -51,7 +52,8 @@ type EditRow = {
|
|||
|
||||
function toEditRow(item: LineItemInput): EditRow {
|
||||
return {
|
||||
description: item.description,
|
||||
name: item.name,
|
||||
description: item.description ?? "",
|
||||
quantity: String(item.quantity),
|
||||
unit: item.unit,
|
||||
size: item.size ?? "",
|
||||
|
|
@ -63,7 +65,8 @@ function toEditRow(item: LineItemInput): EditRow {
|
|||
|
||||
function toLineItem(row: EditRow): LineItemInput {
|
||||
return {
|
||||
description: row.description,
|
||||
name: row.name,
|
||||
description: row.description || undefined,
|
||||
quantity: parseFloat(row.quantity) || 0,
|
||||
unit: row.unit,
|
||||
size: row.size || undefined,
|
||||
|
|
@ -79,14 +82,16 @@ function calcTotals(items: LineItemInput[]) {
|
|||
return { taxable, gst, grand: taxable + gst };
|
||||
}
|
||||
|
||||
function DescriptionCell({
|
||||
value,
|
||||
function NameCell({
|
||||
name,
|
||||
description,
|
||||
productId,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
name: string;
|
||||
description: string;
|
||||
productId?: string;
|
||||
onChange: (desc: string, pid?: string, price?: number) => void;
|
||||
onChange: (name: string, description: string, pid?: string, price?: number) => void;
|
||||
}) {
|
||||
const [hits, setHits] = useState<ProductHit[]>([]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
|
@ -103,8 +108,8 @@ function DescriptionCell({
|
|||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, []);
|
||||
|
||||
function handleInput(v: string) {
|
||||
onChange(v, undefined);
|
||||
function handleNameInput(v: string) {
|
||||
onChange(v, description, undefined);
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (v.length < 2) { setHits([]); setOpen(false); return; }
|
||||
timerRef.current = setTimeout(async () => {
|
||||
|
|
@ -120,49 +125,58 @@ function DescriptionCell({
|
|||
}
|
||||
|
||||
function select(hit: ProductHit) {
|
||||
onChange(hit.name, hit.id, hit.lastPrice ?? undefined);
|
||||
onChange(hit.name, hit.description ?? description, hit.id, hit.lastPrice ?? undefined);
|
||||
setOpen(false);
|
||||
setHits([]);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={wrapRef} className="relative w-full">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => handleInput(e.target.value)}
|
||||
onFocus={() => { if (hits.length > 0) setOpen(true); }}
|
||||
className="w-full rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Item description"
|
||||
/>
|
||||
{productId && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-primary-500 font-mono pointer-events-none select-none">
|
||||
✓ linked
|
||||
</span>
|
||||
)}
|
||||
{open && (
|
||||
<ul className="absolute z-50 left-0 right-0 top-full mt-0.5 max-h-52 overflow-y-auto rounded-lg border border-neutral-200 bg-white shadow-lg text-sm">
|
||||
{hits.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); select(hit); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-primary-50 flex items-start gap-2"
|
||||
>
|
||||
<span className="font-mono text-xs text-neutral-400 shrink-0 mt-0.5 w-28 truncate">{hit.code}</span>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium text-neutral-900">{hit.name}</span>
|
||||
{hit.description && (
|
||||
<span className="block text-xs text-neutral-500 truncate">{hit.description}</span>
|
||||
<div ref={wrapRef} className="relative w-full space-y-1">
|
||||
<div className="relative">
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => handleNameInput(e.target.value)}
|
||||
onFocus={() => { if (hits.length > 0) setOpen(true); }}
|
||||
className="w-full rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Item name *"
|
||||
required
|
||||
/>
|
||||
{productId && (
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs text-primary-500 pointer-events-none select-none">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
{open && (
|
||||
<ul className="absolute z-50 left-0 right-0 top-full mt-0.5 max-h-52 overflow-y-auto rounded-lg border border-neutral-200 bg-white shadow-lg text-sm">
|
||||
{hits.map((hit) => (
|
||||
<li key={hit.id}>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => { e.preventDefault(); select(hit); }}
|
||||
className="w-full text-left px-3 py-2 hover:bg-primary-50 flex items-start gap-2"
|
||||
>
|
||||
<span className="font-mono text-xs text-neutral-400 shrink-0 mt-0.5 w-24 truncate">{hit.code}</span>
|
||||
<span className="flex-1">
|
||||
<span className="font-medium text-neutral-900">{hit.name}</span>
|
||||
{hit.description && (
|
||||
<span className="block text-xs text-neutral-500 truncate">{hit.description}</span>
|
||||
)}
|
||||
</span>
|
||||
{hit.lastPrice != null && (
|
||||
<span className="shrink-0 text-xs text-neutral-500">{formatCurrency(hit.lastPrice)}</span>
|
||||
)}
|
||||
</span>
|
||||
{hit.lastPrice != null && (
|
||||
<span className="shrink-0 text-xs text-neutral-500">{formatCurrency(hit.lastPrice)}</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
value={description}
|
||||
onChange={(e) => onChange(name, e.target.value, productId)}
|
||||
className="w-full rounded border border-neutral-200 px-2 py-1 text-xs text-neutral-500 focus:border-primary-500 focus:outline-none"
|
||||
placeholder="Description (optional)"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -179,13 +193,14 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
updateRows(rows.map((row, i) => (i === index ? { ...row, [field]: value } : row)));
|
||||
}
|
||||
|
||||
function updateDescription(index: number, desc: string, productId?: string, price?: number) {
|
||||
function updateNameCell(index: number, name: string, description: string, productId?: string, price?: number) {
|
||||
updateRows(
|
||||
rows.map((row, i) => {
|
||||
if (i !== index) return row;
|
||||
return {
|
||||
...row,
|
||||
description: desc,
|
||||
name,
|
||||
description,
|
||||
productId: productId ?? undefined,
|
||||
unitPrice: price != null ? String(price) : row.unitPrice,
|
||||
};
|
||||
|
|
@ -194,7 +209,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
}
|
||||
|
||||
function add() {
|
||||
updateRows([...rows, { description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18" }]);
|
||||
updateRows([...rows, { name: "", description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18" }]);
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
|
|
@ -216,7 +231,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200">
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Description</th>
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Item</th>
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Qty</th>
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Unit</th>
|
||||
{hasSize && <th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>}
|
||||
|
|
@ -231,14 +246,17 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
const orig = originalItems?.[i];
|
||||
const qtyChanged = orig && Number(orig.quantity) !== item.quantity;
|
||||
const priceChanged = orig && Number(orig.unitPrice) !== item.unitPrice;
|
||||
const descChanged = orig && orig.description !== item.description;
|
||||
const nameChanged = orig && orig.name !== item.name;
|
||||
const taxableAmt = item.quantity * item.unitPrice;
|
||||
const gstAmt = taxableAmt * (item.gstRate ?? 0.18);
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-2 pr-4">
|
||||
{descChanged && <span className="block text-neutral-400 line-through text-xs">{orig.description}</span>}
|
||||
<span className={descChanged ? "text-amber-700 font-medium" : "text-neutral-900"}>{item.description}</span>
|
||||
{nameChanged && <span className="block text-neutral-400 line-through text-xs">{orig.name}</span>}
|
||||
<span className={nameChanged ? "text-amber-700 font-medium" : "text-neutral-900"}>{item.name}</span>
|
||||
{item.description && (
|
||||
<span className="block text-xs text-neutral-500 mt-0.5">{item.description}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 text-right">
|
||||
{qtyChanged && <span className="block text-neutral-400 line-through text-xs">{Number(orig.quantity)}</span>}
|
||||
|
|
@ -285,7 +303,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-200">
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Description</th>
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 w-full">Item</th>
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Qty</th>
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Unit</th>
|
||||
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>
|
||||
|
|
@ -302,10 +320,11 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-2 pr-4">
|
||||
<DescriptionCell
|
||||
value={row.description}
|
||||
<NameCell
|
||||
name={row.name}
|
||||
description={row.description}
|
||||
productId={row.productId}
|
||||
onChange={(desc, pid, price) => updateDescription(i, desc, pid, price)}
|
||||
onChange={(name, description, pid, price) => updateNameCell(i, name, description, pid, price)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pl-4">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import * as XLSX from "xlsx";
|
||||
|
||||
export type ParsedImportLine = {
|
||||
description: string;
|
||||
name: string;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
|
|
@ -79,7 +79,7 @@ export function parseSheet(sheet: XLSX.WorkSheet): ParsedImport {
|
|||
const gstRate = gstRaw > 1 ? gstRaw / 100 : gstRaw;
|
||||
|
||||
lineItems.push({
|
||||
description: desc,
|
||||
name: desc,
|
||||
unit: unitRaw || "pc",
|
||||
quantity: qty || 1,
|
||||
unitPrice,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { z } from "zod";
|
||||
|
||||
export const lineItemSchema = z.object({
|
||||
description: z.string().min(1, "Description is required"),
|
||||
name: z.string().min(1, "Item name is required"),
|
||||
description: z.string().optional(),
|
||||
quantity: z.coerce.number().positive("Quantity must be positive"),
|
||||
unit: z.string().min(1, "Unit is required"),
|
||||
size: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
-- Add name column nullable first for backfill
|
||||
ALTER TABLE "POLineItem" ADD COLUMN "name" TEXT;
|
||||
|
||||
-- Backfill: copy existing description into name
|
||||
UPDATE "POLineItem" SET "name" = "description";
|
||||
|
||||
-- Make name required
|
||||
ALTER TABLE "POLineItem" ALTER COLUMN "name" SET NOT NULL;
|
||||
|
||||
-- Make description optional
|
||||
ALTER TABLE "POLineItem" ALTER COLUMN "description" DROP NOT NULL;
|
||||
|
|
@ -161,7 +161,8 @@ model PurchaseOrder {
|
|||
|
||||
model POLineItem {
|
||||
id String @id @default(cuid())
|
||||
description String
|
||||
name String
|
||||
description String?
|
||||
quantity Decimal @db.Decimal(10, 3)
|
||||
unit String
|
||||
unitPrice Decimal @db.Decimal(12, 2)
|
||||
|
|
|
|||
|
|
@ -571,9 +571,9 @@ async function main() {
|
|||
vendorId: vendor1.id,
|
||||
lineItems: {
|
||||
create: [
|
||||
{ description: "Turbocharger seal kit", quantity: 2, unit: "set", unitPrice: 1200, totalPrice: 2400, sortOrder: 0, productId: prod1.id },
|
||||
{ description: "High-pressure fuel pump", quantity: 1, unit: "pc", unitPrice: 4800, totalPrice: 4800, sortOrder: 1, productId: prod2.id },
|
||||
{ description: "O-ring assortment pack", quantity: 5, unit: "pk", unitPrice: 250, totalPrice: 1250, sortOrder: 2 },
|
||||
{ name: "Turbocharger seal kit", quantity: 2, unit: "set", unitPrice: 1200, totalPrice: 2400, sortOrder: 0, productId: prod1.id },
|
||||
{ name: "High-pressure fuel pump", quantity: 1, unit: "pc", unitPrice: 4800, totalPrice: 4800, sortOrder: 1, productId: prod2.id },
|
||||
{ name: "O-ring assortment pack", quantity: 5, unit: "pk", unitPrice: 250, totalPrice: 1250, sortOrder: 2 },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
|
|
@ -599,8 +599,8 @@ async function main() {
|
|||
accountId: acc2.id,
|
||||
lineItems: {
|
||||
create: [
|
||||
{ description: "Life jackets (SOLAS)", quantity: 20, unit: "pc", unitPrice: 120, totalPrice: 2400, sortOrder: 0 },
|
||||
{ description: "Fire extinguisher — 9kg", quantity: 4, unit: "pc", unitPrice: 200, totalPrice: 800, sortOrder: 1 },
|
||||
{ name: "Life jackets (SOLAS)", quantity: 20, unit: "pc", unitPrice: 120, totalPrice: 2400, sortOrder: 0 },
|
||||
{ name: "Fire extinguisher — 9kg", quantity: 4, unit: "pc", unitPrice: 200, totalPrice: 800, sortOrder: 1 },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
|
|
@ -625,7 +625,7 @@ async function main() {
|
|||
accountId: acc1.id,
|
||||
lineItems: {
|
||||
create: [
|
||||
{ description: "INT chart folio update", quantity: 1, unit: "set", unitPrice: 950, totalPrice: 950, sortOrder: 0 },
|
||||
{ name: "INT chart folio update", quantity: 1, unit: "set", unitPrice: 950, totalPrice: 950, sortOrder: 0 },
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue