feat(po): admin-managed delivery locations + Place of Delivery dropdown (#19) #100
18 changed files with 578 additions and 17 deletions
|
|
@ -98,6 +98,12 @@ A PO's **cost centre is a Vessel** (the `Vessel` model). `PurchaseOrder.vesselId
|
|||
|
||||
`Company` represents the sister company a PO is billed under (`PurchaseOrder.companyId`, optional). Fields: `name`, `code` (unique short code, e.g. `PMS`), `gstNumber`, `address`, `telephone`, `mobile`, `email`, `invoiceEmail`, `invoiceAddress`. Managed at `/admin/companies`. The selected company's details populate the **exported PO header / invoice block** (falling back to hardcoded Pelagia defaults when no company is linked).
|
||||
|
||||
### Delivery Locations (issue #19)
|
||||
|
||||
`DeliveryLocation` (a `Company` FK + free-text `address` + `isActive`) is an admin-managed list that backs the PO **Place of Delivery** dropdown. Managed at `/admin/delivery-locations`, gated by the **`manage_delivery_locations`** permission (Manager + SuperUser + Admin — explicitly **not** admin-only, per the issue). The CRUD mirrors `/admin/sites` (table + Add/Edit dialogs + activate/deactivate + delete).
|
||||
|
||||
The three PO forms (`new-po-form`, `edit-po-form`, `manager-edit-po-form`) render a shared `<DeliveryLocationField>` — a native `<select name="placeOfDelivery">` populated from the **active** locations, each formatted by `lib/delivery-location.ts` `formatDeliveryLocation(company, address)` → `"Company — address"`. **`PurchaseOrder.placeOfDelivery` stays a free-text snapshot** (no FK): the dropdown only changes how the value is picked, so the export, import, and historical/imported POs are unchanged. On edit, a current value not in the active list is preserved as a leading "(current)" option so it's never silently dropped. Deleting a location is therefore always safe (no PO references it).
|
||||
|
||||
### PO Numbering (`lib/po-number.ts`)
|
||||
|
||||
Structured format: **`COMPANY/VESSEL/PO_ID/FY`** — e.g. `PMS/HNR1/9000/2024-25`. The financial year is Indian (Apr–Mar) rendered `YYYY-YY`. System-generated `PO_ID` starts at **9000** to avoid clashing with historical numbers. **Imported POs keep their original PO number** verbatim; `parsePoNumber()` extracts the company/vessel/id parts on import.
|
||||
|
|
|
|||
77
App/app/(portal)/admin/delivery-locations/actions.ts
Normal file
77
App/app/(portal)/admin/delivery-locations/actions.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
companyId: z.string().min(1, "Company is required"),
|
||||
address: z.string().trim().min(1, "Delivery address is required"),
|
||||
});
|
||||
|
||||
type Result = { ok: true } | { error: string };
|
||||
|
||||
async function guard(): Promise<{ ok: true } | { error: string }> {
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_delivery_locations")) {
|
||||
return { error: "Forbidden" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function createDeliveryLocation(formData: FormData): Promise<Result> {
|
||||
const g = await guard();
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||
|
||||
// Guard against a dangling FK if the company was removed concurrently.
|
||||
const company = await db.company.findUnique({ where: { id: parsed.data.companyId }, select: { id: true } });
|
||||
if (!company) return { error: "Selected company no longer exists." };
|
||||
|
||||
await db.deliveryLocation.create({
|
||||
data: { companyId: parsed.data.companyId, address: parsed.data.address },
|
||||
});
|
||||
revalidatePath("/admin/delivery-locations");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateDeliveryLocation(id: string, formData: FormData): Promise<Result> {
|
||||
const g = await guard();
|
||||
if ("error" in g) return g;
|
||||
|
||||
const parsed = schema.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) return { error: parsed.error.errors[0].message };
|
||||
|
||||
await db.deliveryLocation.update({
|
||||
where: { id },
|
||||
data: { companyId: parsed.data.companyId, address: parsed.data.address },
|
||||
});
|
||||
revalidatePath("/admin/delivery-locations");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function toggleDeliveryLocationActive(id: string): Promise<Result> {
|
||||
const g = await guard();
|
||||
if ("error" in g) return g;
|
||||
|
||||
const loc = await db.deliveryLocation.findUnique({ where: { id }, select: { isActive: true } });
|
||||
if (!loc) return { error: "Not found" };
|
||||
await db.deliveryLocation.update({ where: { id }, data: { isActive: !loc.isActive } });
|
||||
revalidatePath("/admin/delivery-locations");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteDeliveryLocation(id: string): Promise<Result> {
|
||||
const g = await guard();
|
||||
if ("error" in g) return g;
|
||||
|
||||
// Safe to delete: POs keep their place-of-delivery as a text snapshot, so no
|
||||
// purchase order references this row.
|
||||
await db.deliveryLocation.delete({ where: { id } });
|
||||
revalidatePath("/admin/delivery-locations");
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { createDeliveryLocation, updateDeliveryLocation } from "./actions";
|
||||
|
||||
const INPUT = "w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||
|
||||
export type CompanyOption = { id: string; name: string };
|
||||
export type DeliveryLocationRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
companyName: string;
|
||||
address: string;
|
||||
isActive: boolean;
|
||||
};
|
||||
|
||||
function Fields({ companies, location }: { companies: CompanyOption[]; location?: DeliveryLocationRow }) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Company *</label>
|
||||
<select name="companyId" defaultValue={location?.companyId ?? ""} required className={INPUT}>
|
||||
<option value="" disabled>Select a company…</option>
|
||||
{companies.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Delivery address *</label>
|
||||
<textarea name="address" defaultValue={location?.address ?? ""} rows={3} required className={INPUT} placeholder="e.g. Reti Bundar, Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddDeliveryLocationButton({ companies }: { companies: CompanyOption[] }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setPending(true); setError("");
|
||||
const result = await createDeliveryLocation(new FormData(e.currentTarget));
|
||||
if ("error" in result) { setError(result.error); setPending(false); }
|
||||
else { setPending(false); setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => setOpen(true)} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||
+ Add Delivery Location
|
||||
</button>
|
||||
<AdminDialog open={open} onClose={() => setOpen(false)} title="Add Delivery Location">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Fields companies={companies} />
|
||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Create"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditDeliveryLocationButton({
|
||||
companies,
|
||||
location,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
companies: CompanyOption[];
|
||||
location: DeliveryLocationRow;
|
||||
open?: boolean;
|
||||
onOpenChange?: (v: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault(); setPending(true); setError("");
|
||||
const result = await updateDeliveryLocation(location.id, new FormData(e.currentTarget));
|
||||
if ("error" in result) { setError(result.error); setPending(false); }
|
||||
else { setPending(false); setOpen(false); router.refresh(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminDialog open={open} onClose={() => setOpen(false)} title="Edit Delivery Location">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<Fields companies={companies} location={location} />
|
||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded px-3 py-2">{error}</p>}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setOpen(false)} className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700">Cancel</button>
|
||||
<button type="submit" disabled={pending} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white disabled:opacity-60">{pending ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTableControls } from "@/components/ui/use-table-controls";
|
||||
import { TableControls, SortableTh } from "@/components/ui/table-controls";
|
||||
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
AddDeliveryLocationButton,
|
||||
EditDeliveryLocationButton,
|
||||
type CompanyOption,
|
||||
type DeliveryLocationRow,
|
||||
} from "./delivery-location-form";
|
||||
import { deleteDeliveryLocation, toggleDeliveryLocationActive } from "./actions";
|
||||
|
||||
const CHIPS = ["Active", "Inactive"];
|
||||
|
||||
function LocationActionsMenu({ companies, location }: { companies: CompanyOption[]; location: DeliveryLocationRow }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [toggleOpen, setToggleOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RowActionsMenu>
|
||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||
{location.isActive ? "Deactivate" : "Activate"}
|
||||
</RowActionsItem>
|
||||
<RowActionsSeparator />
|
||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||
</RowActionsMenu>
|
||||
|
||||
<EditDeliveryLocationButton companies={companies} location={location} open={editOpen} onOpenChange={setEditOpen} />
|
||||
<DeleteConfirmDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
label={`${location.companyName} — ${location.address}`}
|
||||
onConfirm={() => deleteDeliveryLocation(location.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={toggleOpen}
|
||||
onOpenChange={setToggleOpen}
|
||||
title={location.isActive ? "Deactivate location?" : "Activate location?"}
|
||||
description={
|
||||
location.isActive
|
||||
? "It will no longer appear in the Place of Delivery dropdown."
|
||||
: "It will appear in the Place of Delivery dropdown again."
|
||||
}
|
||||
confirmLabel={location.isActive ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => toggleDeliveryLocationActive(location.id)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeliveryLocationsTable({
|
||||
locations,
|
||||
companies,
|
||||
}: {
|
||||
locations: DeliveryLocationRow[];
|
||||
companies: CompanyOption[];
|
||||
}) {
|
||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||
useTableControls<DeliveryLocationRow>({
|
||||
rows: locations,
|
||||
defaultSortKey: "companyName",
|
||||
searchText: (l) => [l.companyName, l.address, l.isActive ? "active" : "inactive"].join(" "),
|
||||
chipMatch: (l, chip) => {
|
||||
if (chip.toLowerCase() === "active") return l.isActive;
|
||||
if (chip.toLowerCase() === "inactive") return !l.isActive;
|
||||
return false;
|
||||
},
|
||||
sortValue: (l, key) => {
|
||||
if (key === "isActive") return l.isActive ? "Active" : "Inactive";
|
||||
const val = l[key as keyof DeliveryLocationRow];
|
||||
return typeof val === "string" || typeof val === "boolean" ? val : String(val ?? "");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Delivery Locations</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">Destinations that populate the PO “Place of Delivery” dropdown</p>
|
||||
</div>
|
||||
<AddDeliveryLocationButton companies={companies} />
|
||||
</div>
|
||||
|
||||
<TableControls
|
||||
search={search}
|
||||
onSearch={setSearch}
|
||||
searchPlaceholder="Search company or address…"
|
||||
chips={CHIPS}
|
||||
activeFilters={activeFilters}
|
||||
onToggleFilter={toggleFilter}
|
||||
/>
|
||||
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<SortableTh sortKey="companyName" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Company</SortableTh>
|
||||
<SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Address</SortableTh>
|
||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof DeliveryLocationRow)}>Status</SortableTh>
|
||||
<th className="px-4 py-3 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-neutral-400">
|
||||
No delivery locations yet. Add one to populate the Place of Delivery dropdown.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{filtered.map((location) => (
|
||||
<tr key={location.id} className="hover:bg-neutral-50">
|
||||
<td className="px-4 py-3 font-medium text-neutral-900">{location.companyName}</td>
|
||||
<td className="px-4 py-3 text-neutral-600 max-w-md whitespace-pre-wrap">{location.address}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
location.isActive ? "bg-success-100 text-success-700" : "bg-neutral-100 text-neutral-500"
|
||||
}`}>
|
||||
{location.isActive ? "Active" : "Inactive"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<LocationActionsMenu companies={companies} location={location} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
App/app/(portal)/admin/delivery-locations/page.tsx
Normal file
35
App/app/(portal)/admin/delivery-locations/page.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { redirect } from "next/navigation";
|
||||
import { DeliveryLocationsTable } from "./delivery-locations-table";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Delivery Locations" };
|
||||
|
||||
export default async function DeliveryLocationsPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (!hasPermission(session.user.role, "manage_delivery_locations")) redirect("/dashboard");
|
||||
|
||||
const [locations, companies] = await Promise.all([
|
||||
db.deliveryLocation.findMany({
|
||||
orderBy: [{ isActive: "desc" }, { createdAt: "desc" }],
|
||||
include: { company: { select: { name: true } } },
|
||||
}),
|
||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<DeliveryLocationsTable
|
||||
companies={companies}
|
||||
locations={locations.map((l) => ({
|
||||
id: l.id,
|
||||
companyId: l.companyId,
|
||||
companyName: l.company.name,
|
||||
address: l.address,
|
||||
isActive: l.isActive,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import type { LineItemInput } from "@/lib/validations/po";
|
|||
import type { Vendor, PurchaseOrder } from "@prisma/client";
|
||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||
|
||||
type SerializedLineItem = {
|
||||
id: string;
|
||||
|
|
@ -39,6 +40,7 @@ interface Props {
|
|||
accounts: AccountGroup[];
|
||||
vendors: Vendor[];
|
||||
companies: CompanyOption[];
|
||||
deliveryOptions: string[];
|
||||
}
|
||||
|
||||
const INPUT =
|
||||
|
|
@ -51,7 +53,7 @@ function ManagerAccountSelect({ accountId, accounts }: { accountId: string; acco
|
|||
return <SearchableSelect name="accountId" value={value} onChange={setValue} groups={accounts} placeholder="Search accounting code…" required />;
|
||||
}
|
||||
|
||||
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }: Props) {
|
||||
export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions }: Props) {
|
||||
const router = useRouter();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
|
@ -230,7 +232,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
|
|||
<section>
|
||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
||||
<label className={LABEL}>Place of Delivery</label>
|
||||
<textarea name="placeOfDelivery" rows={2} defaultValue={extPo.placeOfDelivery ?? ""} className={INPUT} />
|
||||
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT} />
|
||||
</section>
|
||||
|
||||
{/* Vendor */}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { ApprovalActions } from "./approval-actions";
|
|||
import { PoDetail } from "@/components/po/po-detail";
|
||||
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
});
|
||||
const hasSignature = !!(currentUser?.signatureKey);
|
||||
|
||||
const [po, vessels, leafAccounts, vendors, companies] = await Promise.all([
|
||||
const [po, vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
||||
db.purchaseOrder.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
|
|
@ -52,12 +53,14 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
}),
|
||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||
]);
|
||||
|
||||
if (!po) notFound();
|
||||
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
||||
|
||||
const accounts = buildAccountGroups(leafAccounts);
|
||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||
|
||||
const serializedPo = {
|
||||
...po,
|
||||
|
|
@ -98,6 +101,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
|||
accounts={accounts}
|
||||
vendors={vendors}
|
||||
companies={companies}
|
||||
deliveryOptions={deliveryOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { Vendor, PurchaseOrder } from "@prisma/client";
|
|||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
||||
|
||||
|
|
@ -40,10 +41,11 @@ interface Props {
|
|||
accounts: AccountGroup[];
|
||||
vendors: Vendor[];
|
||||
companies: CompanyOption[];
|
||||
deliveryOptions: string[];
|
||||
managerNoteAuthor?: string | null;
|
||||
}
|
||||
|
||||
export function EditPoForm({ po, vessels, accounts, vendors, companies, managerNoteAuthor }: Props) {
|
||||
export function EditPoForm({ po, vessels, accounts, vendors, companies, deliveryOptions, managerNoteAuthor }: Props) {
|
||||
const router = useRouter();
|
||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||
po.lineItems.map((li) => ({
|
||||
|
|
@ -229,7 +231,7 @@ export function EditPoForm({ po, vessels, accounts, vendors, companies, managerN
|
|||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||
<textarea name="placeOfDelivery" rows={2} className={INPUT_CLS} defaultValue={extPo.placeOfDelivery ?? ""} />
|
||||
<DeliveryLocationField options={deliveryOptions} current={extPo.placeOfDelivery} className={INPUT_CLS} />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { db } from "@/lib/db";
|
|||
import { notFound, redirect } from "next/navigation";
|
||||
import { EditPoForm } from "./edit-po-form";
|
||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||
import type { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -29,7 +30,7 @@ export default async function EditPoPage({ params }: Props) {
|
|||
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||
if (!canEdit) redirect(`/po/${id}`);
|
||||
|
||||
const [vessels, leafAccounts, vendors, companies, noteAction] = await Promise.all([
|
||||
const [vessels, leafAccounts, vendors, companies, deliveryLocations, noteAction] = await Promise.all([
|
||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||
db.account.findMany({
|
||||
where: { isActive: true, children: { none: {} } },
|
||||
|
|
@ -38,6 +39,7 @@ export default async function EditPoPage({ params }: Props) {
|
|||
}),
|
||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||
po.status === "EDITS_REQUESTED"
|
||||
? db.pOAction.findFirst({
|
||||
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
||||
|
|
@ -48,6 +50,7 @@ export default async function EditPoPage({ params }: Props) {
|
|||
]);
|
||||
|
||||
const accounts = buildAccountGroups(leafAccounts);
|
||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||
|
||||
const serializedPo = {
|
||||
...po,
|
||||
|
|
@ -73,6 +76,7 @@ export default async function EditPoPage({ params }: Props) {
|
|||
accounts={accounts}
|
||||
vendors={vendors}
|
||||
companies={companies}
|
||||
deliveryOptions={deliveryOptions}
|
||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { Vendor } from "@prisma/client";
|
|||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { FileUploader } from "@/components/po/file-uploader";
|
||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } from "@/lib/validations/po";
|
||||
|
|
@ -25,13 +26,14 @@ interface Props {
|
|||
accounts: AccountGroup[];
|
||||
vendors: Vendor[];
|
||||
companies: CompanyOption[];
|
||||
deliveryOptions: string[];
|
||||
initialLineItems?: LineItemInput[];
|
||||
initialVendorId?: string;
|
||||
initialVesselId?: string;
|
||||
initialCompanyId?: string;
|
||||
}
|
||||
|
||||
export function NewPoForm({ vessels, accounts, vendors, companies, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
||||
export function NewPoForm({ vessels, accounts, vendors, companies, deliveryOptions, initialLineItems, initialVendorId, initialVesselId, initialCompanyId }: Props) {
|
||||
const router = useRouter();
|
||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
||||
|
|
@ -194,12 +196,12 @@ export function NewPoForm({ vessels, accounts, vendors, companies, initialLineIt
|
|||
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||
<textarea
|
||||
name="placeOfDelivery"
|
||||
rows={2}
|
||||
className={INPUT_CLS}
|
||||
defaultValue="Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614"
|
||||
/>
|
||||
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
|
||||
{deliveryOptions.length === 0 && (
|
||||
<p className="mt-1.5 text-xs text-neutral-500">
|
||||
No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { hasPermission } from "@/lib/permissions";
|
|||
import { redirect } from "next/navigation";
|
||||
import { NewPoForm } from "./new-po-form";
|
||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||
import type { Metadata } from "next";
|
||||
import type { LineItemInput } from "@/lib/validations/po";
|
||||
import type { CartItem } from "@/lib/cart";
|
||||
|
|
@ -46,7 +47,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
const [vessels, leafAccounts, vendors, companies] = await Promise.all([
|
||||
const [vessels, leafAccounts, vendors, companies, deliveryLocations] = await Promise.all([
|
||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||
db.account.findMany({
|
||||
where: { isActive: true, children: { none: {} } },
|
||||
|
|
@ -55,9 +56,11 @@ export default async function NewPoPage({ searchParams }: Props) {
|
|||
}),
|
||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||
db.company.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||
db.deliveryLocation.findMany({ where: { isActive: true }, orderBy: { createdAt: "asc" }, include: { company: { select: { name: true } } } }),
|
||||
]);
|
||||
|
||||
const accounts = buildAccountGroups(leafAccounts);
|
||||
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
|
|
@ -72,6 +75,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
|||
accounts={accounts}
|
||||
vendors={vendors}
|
||||
companies={companies}
|
||||
deliveryOptions={deliveryOptions}
|
||||
initialLineItems={initialLineItems}
|
||||
initialVendorId={initialVendorId}
|
||||
initialVesselId={initialVesselId}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import {
|
|||
UserCog,
|
||||
Gauge,
|
||||
BadgeCheck,
|
||||
Truck,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
|
@ -104,8 +105,9 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
|||
// ── Administration section ────────────────────────────────────────────────────
|
||||
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
||||
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||
{ href: "/admin/delivery-locations", label: "Delivery Locations", icon: Truck, roles: ["MANAGER", "SUPERUSER", "ADMIN"] },
|
||||
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
||||
...(CREWING_ENABLED
|
||||
? [
|
||||
|
|
|
|||
36
App/components/po/delivery-location-field.tsx
Normal file
36
App/components/po/delivery-location-field.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* Place-of-Delivery dropdown (issue #19) — a native <select name="placeOfDelivery">
|
||||
* sourced from the admin-managed delivery locations. Plain HTML so it works with
|
||||
* the forms' native FormData submission (no client state needed).
|
||||
*
|
||||
* `options` are the formatted "Company — address" strings (also the stored value).
|
||||
* `current` is the PO's existing place-of-delivery; if it isn't one of the active
|
||||
* options (legacy / imported / a since-removed location) it is preserved as a
|
||||
* leading "(current)" option so an edit never silently drops it.
|
||||
*/
|
||||
export function DeliveryLocationField({
|
||||
options,
|
||||
current,
|
||||
className,
|
||||
}: {
|
||||
options: string[];
|
||||
current?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
const cur = (current ?? "").trim();
|
||||
const currentMissing = cur.length > 0 && !options.includes(cur);
|
||||
|
||||
return (
|
||||
<select name="placeOfDelivery" defaultValue={cur} className={className}>
|
||||
<option value="">— Select a delivery location —</option>
|
||||
{currentMissing && <option value={cur}>{cur} (current)</option>}
|
||||
{options.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
9
App/lib/delivery-location.ts
Normal file
9
App/lib/delivery-location.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/**
|
||||
* Delivery locations (issue #19) — admin-managed destinations used to populate
|
||||
* the PO "Place of Delivery" dropdown. A location is a Company + a free-text
|
||||
* address; the PO stores the resolved single string below as a point-in-time
|
||||
* snapshot in `PurchaseOrder.placeOfDelivery`.
|
||||
*/
|
||||
export function formatDeliveryLocation(companyName: string, address: string): string {
|
||||
return `${companyName} — ${address}`.trim();
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ export type Permission =
|
|||
| "manage_vessels_accounts"
|
||||
| "manage_products"
|
||||
| "manage_sites"
|
||||
| "manage_delivery_locations"
|
||||
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
||||
| "raise_requisition"
|
||||
| "request_relief_cover"
|
||||
|
|
@ -81,6 +82,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"manage_vessels_accounts",
|
||||
"manage_products",
|
||||
"manage_sites",
|
||||
"manage_delivery_locations",
|
||||
"confirm_receipt",
|
||||
"process_payment"
|
||||
],
|
||||
|
|
@ -100,6 +102,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"view_analytics",
|
||||
"export_reports",
|
||||
"create_vendor",
|
||||
"manage_delivery_locations",
|
||||
],
|
||||
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
||||
ADMIN: [
|
||||
|
|
@ -113,6 +116,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"manage_vessels_accounts",
|
||||
"manage_products",
|
||||
"manage_sites",
|
||||
"manage_delivery_locations",
|
||||
],
|
||||
SITE_STAFF: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
-- CreateTable
|
||||
CREATE TABLE "DeliveryLocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"companyId" TEXT NOT NULL,
|
||||
"address" TEXT NOT NULL,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "DeliveryLocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "DeliveryLocation_companyId_idx" ON "DeliveryLocation"("companyId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "DeliveryLocation" ADD CONSTRAINT "DeliveryLocation_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
|
@ -389,7 +389,25 @@ model Company {
|
|||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
purchaseOrders PurchaseOrder[]
|
||||
purchaseOrders PurchaseOrder[]
|
||||
deliveryLocations DeliveryLocation[]
|
||||
}
|
||||
|
||||
// Admin-managed delivery destinations (issue #19). Each is a Company + a
|
||||
// free-text address; the PO "Place of Delivery" field becomes a dropdown sourced
|
||||
// from these. The PO stores the resolved text snapshot in
|
||||
// PurchaseOrder.placeOfDelivery (point-in-time document), so deleting/editing a
|
||||
// location never rewrites historical POs. Managed by manage_delivery_locations.
|
||||
model DeliveryLocation {
|
||||
id String @id @default(cuid())
|
||||
companyId String
|
||||
company Company @relation(fields: [companyId], references: [id])
|
||||
address String
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([companyId])
|
||||
}
|
||||
|
||||
model Account {
|
||||
|
|
|
|||
89
App/tests/integration/delivery-locations.test.ts
Normal file
89
App/tests/integration/delivery-locations.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
/**
|
||||
* Integration tests for the Delivery Locations admin CRUD (issue #19).
|
||||
* Covers create/update/toggle/delete + the manage_delivery_locations guard.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
createDeliveryLocation,
|
||||
updateDeliveryLocation,
|
||||
toggleDeliveryLocationActive,
|
||||
deleteDeliveryLocation,
|
||||
} from "@/app/(portal)/admin/delivery-locations/actions";
|
||||
import { makeSession, fd } from "./helpers";
|
||||
|
||||
const mockedAuth = vi.mocked(auth);
|
||||
const PREFIX = "INTTEST_DELLOC_";
|
||||
let companyId: string;
|
||||
|
||||
const asManager = () => mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
|
||||
beforeAll(async () => {
|
||||
const company = await db.company.create({ data: { name: `${PREFIX}Co`, code: "ZZDELLOC" } });
|
||||
companyId = company.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.deliveryLocation.deleteMany({ where: { companyId } });
|
||||
await db.company.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
||||
});
|
||||
|
||||
describe("createDeliveryLocation", () => {
|
||||
it("persists a location tied to its company", async () => {
|
||||
asManager();
|
||||
const result = await createDeliveryLocation(fd({ companyId, address: "Dock 4, Mumbai" }));
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const loc = await db.deliveryLocation.findFirstOrThrow({ where: { companyId, address: "Dock 4, Mumbai" } });
|
||||
expect(loc.isActive).toBe(true);
|
||||
expect(loc.companyId).toBe(companyId);
|
||||
});
|
||||
|
||||
it("requires both a company and an address", async () => {
|
||||
asManager();
|
||||
expect("error" in (await createDeliveryLocation(fd({ companyId, address: " " })))).toBe(true);
|
||||
expect("error" in (await createDeliveryLocation(fd({ companyId: "", address: "x" })))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a company that no longer exists", async () => {
|
||||
asManager();
|
||||
const result = await createDeliveryLocation(fd({ companyId: "nonexistent", address: "x" }));
|
||||
expect("error" in result).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses callers without manage_delivery_locations", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
expect(await createDeliveryLocation(fd({ companyId, address: "x" }))).toEqual({ error: "Forbidden" });
|
||||
mockedAuth.mockResolvedValue(makeSession("u-acc", "ACCOUNTS") as never);
|
||||
expect(await createDeliveryLocation(fd({ companyId, address: "x" }))).toEqual({ error: "Forbidden" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateDeliveryLocation / toggle / delete", () => {
|
||||
it("edits, toggles active, then deletes a location", async () => {
|
||||
asManager();
|
||||
await createDeliveryLocation(fd({ companyId, address: "Old Address" }));
|
||||
const loc = await db.deliveryLocation.findFirstOrThrow({ where: { companyId, address: "Old Address" } });
|
||||
|
||||
expect(await updateDeliveryLocation(loc.id, fd({ companyId, address: "New Address" }))).toEqual({ ok: true });
|
||||
expect((await db.deliveryLocation.findUniqueOrThrow({ where: { id: loc.id } })).address).toBe("New Address");
|
||||
|
||||
expect(await toggleDeliveryLocationActive(loc.id)).toEqual({ ok: true });
|
||||
expect((await db.deliveryLocation.findUniqueOrThrow({ where: { id: loc.id } })).isActive).toBe(false);
|
||||
|
||||
expect(await deleteDeliveryLocation(loc.id)).toEqual({ ok: true });
|
||||
expect(await db.deliveryLocation.findUnique({ where: { id: loc.id } })).toBeNull();
|
||||
});
|
||||
|
||||
it("guards update/toggle/delete behind the permission", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
expect(await updateDeliveryLocation("x", fd({ companyId, address: "y" }))).toEqual({ error: "Forbidden" });
|
||||
expect(await toggleDeliveryLocationActive("x")).toEqual({ error: "Forbidden" });
|
||||
expect(await deleteDeliveryLocation("x")).toEqual({ error: "Forbidden" });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue