feat(po): multi-account support per line item
Schema - POLineItem.accountId String? — optional per-row account override - Account.lineItems back-relation added - Migration: 20260514185519 Line Items Editor - New props: multiAccount, accounts, defaultAccountId - When multiAccount=true: Account column appears in the editable table, each row pre-selected with defaultAccountId, independently changeable - Read-only view: Account column shown when any line item carries an accountId (displays account code) New & Edit PO forms - "Multiple accounts" checkbox (default off) sits inline with the Account label - When checked: label changes to "Default Account / Cost Centre"; per-row dropdowns appear in the line items table pre-filled with the chosen default account - Edit form: checkbox initialises to checked when the PO already has per-line accounts; existing accountId values are pre-populated - accountId per line item is included in form data and persisted Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9d1496a3bf
commit
e9ed0f8eb0
8 changed files with 136 additions and 24 deletions
|
|
@ -18,6 +18,7 @@ function parseLineItems(formData: FormData) {
|
|||
size: (formData.get(`lineItems[${i}].size`) as string) || undefined,
|
||||
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
||||
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
||||
accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
|
@ -110,6 +111,7 @@ export async function updatePo(
|
|||
totalPrice: item.quantity * item.unitPrice,
|
||||
gstRate: item.gstRate,
|
||||
sortOrder: idx,
|
||||
accountId: (item as { accountId?: string }).accountId ?? null,
|
||||
})),
|
||||
},
|
||||
actions: {
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ type SerializedLineItem = {
|
|||
gstRate: number;
|
||||
sortOrder: number;
|
||||
productId: string | null;
|
||||
accountId: string | null;
|
||||
};
|
||||
|
||||
type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
|
||||
|
|
@ -49,10 +50,14 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
size: li.size ?? undefined,
|
||||
unitPrice: li.unitPrice,
|
||||
gstRate: li.gstRate,
|
||||
accountId: li.accountId ?? undefined,
|
||||
}))
|
||||
);
|
||||
const [submitting, setSubmitting] = useState<"save" | "resubmit" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const hasPerLineAccounts = po.lineItems.some((li) => li.accountId);
|
||||
const [multiAccount, setMultiAccount] = useState(hasPerLineAccounts);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState(po.accountId ?? "");
|
||||
|
||||
const canResubmit = po.status === "EDITS_REQUESTED";
|
||||
|
||||
|
|
@ -70,6 +75,7 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
data.set(`lineItems[${i}].size`, item.size ?? "");
|
||||
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
|
||||
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
||||
if (multiAccount && item.accountId) data.set(`lineItems[${i}].accountId`, item.accountId);
|
||||
});
|
||||
|
||||
const result = await updatePo(po.id, data);
|
||||
|
|
@ -124,10 +130,27 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||
Account / Cost Centre <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select name="accountId" required defaultValue={po.accountId} className={INPUT_CLS}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-medium text-neutral-700">
|
||||
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={multiAccount}
|
||||
onChange={(e) => setMultiAccount(e.target.checked)}
|
||||
className="rounded border-neutral-300"
|
||||
/>
|
||||
Multiple accounts
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
name="accountId"
|
||||
required
|
||||
value={defaultAccountId}
|
||||
onChange={(e) => setDefaultAccountId(e.target.value)}
|
||||
className={INPUT_CLS}
|
||||
>
|
||||
<option value="">Select account…</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||
</select>
|
||||
|
|
@ -185,7 +208,13 @@ export function EditPoForm({ po, vessels, accounts, vendors }: Props) {
|
|||
{/* Line Items */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||
<LineItemsEditor items={lineItems} onChange={setLineItems} />
|
||||
<LineItemsEditor
|
||||
items={lineItems}
|
||||
onChange={setLineItems}
|
||||
multiAccount={multiAccount}
|
||||
accounts={accounts.map((a) => ({ id: a.id, name: a.name, code: a.code }))}
|
||||
defaultAccountId={defaultAccountId || undefined}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Vendor */}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ export async function createPo(
|
|||
unitPrice: number;
|
||||
gstRate: number;
|
||||
productId?: string;
|
||||
accountId?: string;
|
||||
}> = [];
|
||||
let i = 0;
|
||||
while (formData.has(`lineItems[${i}].name`)) {
|
||||
|
|
@ -43,6 +44,7 @@ export async function createPo(
|
|||
unitPrice: Number(formData.get(`lineItems[${i}].unitPrice`)),
|
||||
gstRate: Number(formData.get(`lineItems[${i}].gstRate`) ?? 0.18),
|
||||
productId: (formData.get(`lineItems[${i}].productId`) as string) || undefined,
|
||||
accountId: (formData.get(`lineItems[${i}].accountId`) as string) || undefined,
|
||||
});
|
||||
i++;
|
||||
}
|
||||
|
|
@ -116,6 +118,7 @@ export async function createPo(
|
|||
gstRate: item.gstRate,
|
||||
sortOrder: idx,
|
||||
productId: item.productId ?? null,
|
||||
accountId: item.accountId ?? null,
|
||||
})),
|
||||
},
|
||||
actions: {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
|||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [submitting, setSubmitting] = useState<"draft" | "submit" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [multiAccount, setMultiAccount] = useState(false);
|
||||
const [defaultAccountId, setDefaultAccountId] = useState("");
|
||||
|
||||
async function handleSubmit(intent: "draft" | "submit") {
|
||||
setSubmitting(intent);
|
||||
|
|
@ -43,6 +45,7 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
|||
data.set(`lineItems[${i}].unitPrice`, String(item.unitPrice));
|
||||
data.set(`lineItems[${i}].gstRate`, String(item.gstRate ?? 0.18));
|
||||
if (item.productId) data.set(`lineItems[${i}].productId`, item.productId);
|
||||
if (multiAccount && item.accountId) data.set(`lineItems[${i}].accountId`, item.accountId);
|
||||
});
|
||||
|
||||
const result = await createPo(data);
|
||||
|
|
@ -84,10 +87,27 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
|||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||
Account / Cost Centre <span className="text-danger">*</span>
|
||||
</label>
|
||||
<select name="accountId" required className={INPUT_CLS}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<label className="text-sm font-medium text-neutral-700">
|
||||
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={multiAccount}
|
||||
onChange={(e) => setMultiAccount(e.target.checked)}
|
||||
className="rounded border-neutral-300"
|
||||
/>
|
||||
Multiple accounts
|
||||
</label>
|
||||
</div>
|
||||
<select
|
||||
name="accountId"
|
||||
required
|
||||
value={defaultAccountId}
|
||||
onChange={(e) => setDefaultAccountId(e.target.value)}
|
||||
className={INPUT_CLS}
|
||||
>
|
||||
<option value="">Select account…</option>
|
||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||
</select>
|
||||
|
|
@ -150,7 +170,13 @@ export function NewPoForm({ vessels, accounts, vendors }: Props) {
|
|||
{/* Line Items */}
|
||||
<section className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Line Items</h2>
|
||||
<LineItemsEditor items={lineItems} onChange={setLineItems} />
|
||||
<LineItemsEditor
|
||||
items={lineItems}
|
||||
onChange={setLineItems}
|
||||
multiAccount={multiAccount}
|
||||
accounts={accounts.map((a) => ({ id: a.id, name: a.name, code: a.code }))}
|
||||
defaultAccountId={defaultAccountId || undefined}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Vendor */}
|
||||
|
|
|
|||
|
|
@ -33,11 +33,18 @@ type ProductHit = {
|
|||
vendorPrices: { vendorId: string; vendorName: string; price: number }[];
|
||||
};
|
||||
|
||||
export type AccountOption = { id: string; name: string; code: string };
|
||||
|
||||
interface Props {
|
||||
items: LineItemInput[];
|
||||
onChange?: (items: LineItemInput[]) => void;
|
||||
readOnly?: boolean;
|
||||
originalItems?: LineItemInput[];
|
||||
/** When true, show per-row account selector */
|
||||
multiAccount?: boolean;
|
||||
accounts?: AccountOption[];
|
||||
/** The PO-level default account id — pre-selected in each row dropdown */
|
||||
defaultAccountId?: string;
|
||||
}
|
||||
|
||||
type EditRow = {
|
||||
|
|
@ -49,6 +56,7 @@ type EditRow = {
|
|||
unitPrice: string;
|
||||
gstRate: string;
|
||||
productId?: string;
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
function toEditRow(item: LineItemInput): EditRow {
|
||||
|
|
@ -61,6 +69,7 @@ function toEditRow(item: LineItemInput): EditRow {
|
|||
unitPrice: item.unitPrice ? String(item.unitPrice) : "",
|
||||
gstRate: String(item.gstRate ?? 0.18),
|
||||
productId: item.productId,
|
||||
accountId: item.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +83,7 @@ function toLineItem(row: EditRow): LineItemInput {
|
|||
unitPrice: parseFloat(row.unitPrice) || 0,
|
||||
gstRate: parseFloat(row.gstRate) || 0.18,
|
||||
productId: row.productId || undefined,
|
||||
accountId: row.accountId || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -187,7 +197,15 @@ function NameCell({
|
|||
);
|
||||
}
|
||||
|
||||
export function LineItemsEditor({ items, onChange, readOnly = false, originalItems }: Props) {
|
||||
export function LineItemsEditor({
|
||||
items,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
originalItems,
|
||||
multiAccount = false,
|
||||
accounts = [],
|
||||
defaultAccountId,
|
||||
}: Props) {
|
||||
const [rows, setRows] = useState<EditRow[]>(() => items.map(toEditRow));
|
||||
|
||||
function updateRows(next: EditRow[]) {
|
||||
|
|
@ -215,16 +233,23 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
}
|
||||
|
||||
function add() {
|
||||
updateRows([...rows, { name: "", description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18" }]);
|
||||
updateRows([...rows, {
|
||||
name: "", description: "", quantity: "1", unit: "pc", size: "", unitPrice: "", gstRate: "0.18",
|
||||
accountId: defaultAccountId,
|
||||
}]);
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
updateRows(rows.filter((_, i) => i !== index));
|
||||
}
|
||||
|
||||
const accountMap = Object.fromEntries(accounts.map((a) => [a.id, a]));
|
||||
|
||||
// ── Read-only view ───────────────────────────────────────────────────────────
|
||||
if (readOnly) {
|
||||
const hasSize = items.some((item) => item.size);
|
||||
const hasDiff = originalItems && originalItems.length > 0;
|
||||
const showAccount = items.some((item) => item.accountId);
|
||||
const { taxable, gst, grand } = calcTotals(items);
|
||||
|
||||
return (
|
||||
|
|
@ -245,6 +270,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Taxable</th>
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
|
||||
{showAccount && <th className="pb-2 text-left font-medium text-neutral-600 pl-4 whitespace-nowrap">Account</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
|
|
@ -255,6 +281,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
const nameChanged = orig && orig.name !== item.name;
|
||||
const taxableAmt = item.quantity * item.unitPrice;
|
||||
const gstAmt = taxableAmt * (item.gstRate ?? 0.18);
|
||||
const acct = item.accountId ? accountMap[item.accountId] : null;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td className="py-2 pr-4">
|
||||
|
|
@ -277,21 +304,26 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
<td className="py-2 pl-4 text-right">{formatCurrency(taxableAmt)}</td>
|
||||
<td className="py-2 pl-4 text-right text-neutral-500">{Math.round((item.gstRate ?? 0.18) * 100)}%</td>
|
||||
<td className="py-2 pl-4 text-right">{formatCurrency(taxableAmt + gstAmt)}</td>
|
||||
{showAccount && (
|
||||
<td className="py-2 pl-4 text-xs text-neutral-500 font-mono">
|
||||
{acct ? acct.code : "—"}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-neutral-100">
|
||||
<td colSpan={hasSize ? 5 : 4} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
|
||||
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
|
||||
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(taxable)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={hasSize ? 5 : 4} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
|
||||
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
|
||||
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={3}>{formatCurrency(gst)}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-neutral-200">
|
||||
<td colSpan={hasSize ? 5 : 4} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
|
||||
<td colSpan={hasSize ? (showAccount ? 6 : 5) : (showAccount ? 5 : 4)} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
|
||||
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={3}>{formatCurrency(grand)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
@ -300,6 +332,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
);
|
||||
}
|
||||
|
||||
// ── Edit view ────────────────────────────────────────────────────────────────
|
||||
const liveItems = rows.map(toLineItem);
|
||||
const { taxable, gst, grand } = calcTotals(liveItems);
|
||||
|
||||
|
|
@ -315,6 +348,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
<th className="pb-2 text-left font-medium text-neutral-600 pl-3 whitespace-nowrap">Size</th>
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Unit Price</th>
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">GST%</th>
|
||||
{multiAccount && <th className="pb-2 text-left font-medium text-neutral-600 pl-4 whitespace-nowrap">Account</th>}
|
||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4 whitespace-nowrap">Total</th>
|
||||
<th className="pb-2 pl-4 w-8" />
|
||||
</tr>
|
||||
|
|
@ -335,9 +369,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
</td>
|
||||
<td className="py-2 pl-4">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
type="number" min="0" step="any"
|
||||
value={row.quantity}
|
||||
onChange={(e) => update(i, "quantity", e.target.value)}
|
||||
className="w-20 rounded border border-neutral-200 px-2 py-1.5 text-sm text-right focus:border-primary-500 focus:outline-none"
|
||||
|
|
@ -364,9 +396,7 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
</td>
|
||||
<td className="py-2 pl-4">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
step="any"
|
||||
type="number" min="0" step="any"
|
||||
value={row.unitPrice}
|
||||
onChange={(e) => update(i, "unitPrice", e.target.value)}
|
||||
placeholder="0.00"
|
||||
|
|
@ -386,6 +416,19 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
<option value="0.28">28%</option>
|
||||
</select>
|
||||
</td>
|
||||
{multiAccount && (
|
||||
<td className="py-2 pl-4">
|
||||
<select
|
||||
value={row.accountId ?? defaultAccountId ?? ""}
|
||||
onChange={(e) => update(i, "accountId", e.target.value)}
|
||||
className="w-36 rounded border border-neutral-200 px-2 py-1.5 text-sm focus:border-primary-500 focus:outline-none"
|
||||
>
|
||||
{accounts.map((a) => (
|
||||
<option key={a.id} value={a.id}>{a.name} ({a.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
)}
|
||||
<td className="py-2 pl-4 text-right text-sm">
|
||||
{formatCurrency(taxableAmt * (1 + gstR))}
|
||||
</td>
|
||||
|
|
@ -406,15 +449,15 @@ export function LineItemsEditor({ items, onChange, readOnly = false, originalIte
|
|||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t border-neutral-100">
|
||||
<td colSpan={6} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
|
||||
<td colSpan={multiAccount ? 7 : 6} className="pt-3 text-right text-sm text-neutral-500">Taxable subtotal</td>
|
||||
<td className="pt-3 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(taxable)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={6} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
|
||||
<td colSpan={multiAccount ? 7 : 6} className="py-0.5 text-right text-sm text-neutral-500">GST</td>
|
||||
<td className="py-0.5 pl-4 text-right text-sm text-neutral-700" colSpan={2}>{formatCurrency(gst)}</td>
|
||||
</tr>
|
||||
<tr className="border-t border-neutral-200">
|
||||
<td colSpan={6} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
|
||||
<td colSpan={multiAccount ? 7 : 6} className="pt-2 text-right text-sm font-semibold text-neutral-900">Grand Total</td>
|
||||
<td className="pt-2 pl-4 text-right text-sm font-semibold text-neutral-900" colSpan={2}>{formatCurrency(grand)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ export const lineItemSchema = z.object({
|
|||
unitPrice: z.coerce.number().nonnegative("Unit price must be non-negative"),
|
||||
gstRate: z.coerce.number().min(0).max(1).default(0.18),
|
||||
productId: z.string().optional(),
|
||||
accountId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const TC_FIXED_LINE =
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "POLineItem" ADD COLUMN "accountId" TEXT;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "POLineItem" ADD CONSTRAINT "POLineItem_accountId_fkey" FOREIGN KEY ("accountId") REFERENCES "Account"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -101,6 +101,7 @@ model Account {
|
|||
isActive Boolean @default(true)
|
||||
|
||||
purchaseOrders PurchaseOrder[]
|
||||
lineItems POLineItem[]
|
||||
}
|
||||
|
||||
model Vendor {
|
||||
|
|
@ -244,6 +245,8 @@ model POLineItem {
|
|||
size String?
|
||||
productId String?
|
||||
product Product? @relation(fields: [productId], references: [id])
|
||||
accountId String?
|
||||
account Account? @relation(fields: [accountId], references: [id])
|
||||
|
||||
poId String
|
||||
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue