Merge pull request 'feat(po): admin-managed delivery locations + Place of Delivery dropdown (#19)' (#100) from feat/delivery-locations into master
All checks were successful
Refresh staging / refresh (push) Successful in 7s
All checks were successful
Refresh staging / refresh (push) Successful in 7s
Reviewed-on: #100
This commit is contained in:
commit
144d44ccca
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).
|
`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`)
|
### 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.
|
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 { Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
import type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
|
|
||||||
type SerializedLineItem = {
|
type SerializedLineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +40,7 @@ interface Props {
|
||||||
accounts: AccountGroup[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INPUT =
|
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 />;
|
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 router = useRouter();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -230,7 +232,7 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors, companies }:
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
<h3 className="text-xs font-bold text-amber-800 uppercase tracking-wider mb-3">Delivery</h3>
|
||||||
<label className={LABEL}>Place of Delivery</label>
|
<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>
|
</section>
|
||||||
|
|
||||||
{/* Vendor */}
|
{/* Vendor */}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { ApprovalActions } from "./approval-actions";
|
||||||
import { PoDetail } from "@/components/po/po-detail";
|
import { PoDetail } from "@/components/po/po-detail";
|
||||||
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
import { ManagerEditPoForm } from "./manager-edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
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 { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
|
|
@ -29,7 +30,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
const hasSignature = !!(currentUser?.signatureKey);
|
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({
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
|
|
@ -52,12 +53,14 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
}),
|
}),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
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.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) notFound();
|
||||||
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
||||||
|
|
||||||
const accounts = buildAccountGroups(leafAccounts);
|
const accounts = buildAccountGroups(leafAccounts);
|
||||||
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -98,6 +101,7 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 type { VesselOption, AccountGroup, CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } 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[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
managerNoteAuthor?: string | null;
|
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 router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
po.lineItems.map((li) => ({
|
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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
<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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { db } from "@/lib/db";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { EditPoForm } from "./edit-po-form";
|
import { EditPoForm } from "./edit-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
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 { CompanyOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
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";
|
const canEdit = po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
if (!canEdit) redirect(`/po/${id}`);
|
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.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
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.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.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"
|
po.status === "EDITS_REQUESTED"
|
||||||
? db.pOAction.findFirst({
|
? db.pOAction.findFirst({
|
||||||
where: { poId: po.id, actionType: "EDITS_REQUESTED", note: { not: null } },
|
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 accounts = buildAccountGroups(leafAccounts);
|
||||||
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
|
|
@ -73,6 +76,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
managerNoteAuthor={noteAction?.actor.name ?? null}
|
managerNoteAuthor={noteAction?.actor.name ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import type { Vendor } from "@prisma/client";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { FileUploader } from "@/components/po/file-uploader";
|
import { FileUploader } from "@/components/po/file-uploader";
|
||||||
import { SearchableSelect } from "@/components/ui/searchable-select";
|
import { SearchableSelect } from "@/components/ui/searchable-select";
|
||||||
|
import { DeliveryLocationField } from "@/components/po/delivery-location-field";
|
||||||
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
import { uploadAndLinkFiles } from "@/lib/upload-files";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE, TC_FIXED_LINE_2 } 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[];
|
accounts: AccountGroup[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
companies: CompanyOption[];
|
companies: CompanyOption[];
|
||||||
|
deliveryOptions: string[];
|
||||||
initialLineItems?: LineItemInput[];
|
initialLineItems?: LineItemInput[];
|
||||||
initialVendorId?: string;
|
initialVendorId?: string;
|
||||||
initialVesselId?: string;
|
initialVesselId?: string;
|
||||||
initialCompanyId?: 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 router = useRouter();
|
||||||
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
const [lineItems, setLineItems] = useState<LineItemInput[]>(
|
||||||
initialLineItems && initialLineItems.length > 0 ? initialLineItems : [EMPTY_LINE]
|
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>
|
<h2 className="text-base font-semibold text-neutral-900 mb-4">Delivery</h2>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">Place of Delivery</label>
|
||||||
<textarea
|
<DeliveryLocationField options={deliveryOptions} className={INPUT_CLS} />
|
||||||
name="placeOfDelivery"
|
{deliveryOptions.length === 0 && (
|
||||||
rows={2}
|
<p className="mt-1.5 text-xs text-neutral-500">
|
||||||
className={INPUT_CLS}
|
No delivery locations configured yet — a Manager can add them under Administration → Delivery Locations.
|
||||||
defaultValue="Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, CBD Belapur, Navi Mumbai - 400614"
|
</p>
|
||||||
/>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { hasPermission } from "@/lib/permissions";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { NewPoForm } from "./new-po-form";
|
import { NewPoForm } from "./new-po-form";
|
||||||
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
import { buildAccountGroups } from "@/lib/cost-centre-groups";
|
||||||
|
import { formatDeliveryLocation } from "@/lib/delivery-location";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { CartItem } from "@/lib/cart";
|
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.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({
|
db.account.findMany({
|
||||||
where: { isActive: true, children: { none: {} } },
|
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.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.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 accounts = buildAccountGroups(leafAccounts);
|
||||||
|
const deliveryOptions = deliveryLocations.map((l) => formatDeliveryLocation(l.company.name, l.address));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
|
|
@ -72,6 +75,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
|
deliveryOptions={deliveryOptions}
|
||||||
initialLineItems={initialLineItems}
|
initialLineItems={initialLineItems}
|
||||||
initialVendorId={initialVendorId}
|
initialVendorId={initialVendorId}
|
||||||
initialVesselId={initialVesselId}
|
initialVesselId={initialVesselId}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ import {
|
||||||
UserCog,
|
UserCog,
|
||||||
Gauge,
|
Gauge,
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
|
Truck,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { Role } from "@prisma/client";
|
import type { Role } from "@prisma/client";
|
||||||
|
|
@ -104,8 +105,9 @@ const CREWING_ITEMS: NavItem[] = CREWING_ENABLED
|
||||||
// ── Administration section ────────────────────────────────────────────────────
|
// ── Administration section ────────────────────────────────────────────────────
|
||||||
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
||||||
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "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 reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
||||||
...(CREWING_ENABLED
|
...(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_vessels_accounts"
|
||||||
| "manage_products"
|
| "manage_products"
|
||||||
| "manage_sites"
|
| "manage_sites"
|
||||||
|
| "manage_delivery_locations"
|
||||||
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
||||||
| "raise_requisition"
|
| "raise_requisition"
|
||||||
| "request_relief_cover"
|
| "request_relief_cover"
|
||||||
|
|
@ -81,6 +82,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_vessels_accounts",
|
"manage_vessels_accounts",
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
|
"manage_delivery_locations",
|
||||||
"confirm_receipt",
|
"confirm_receipt",
|
||||||
"process_payment"
|
"process_payment"
|
||||||
],
|
],
|
||||||
|
|
@ -100,6 +102,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"view_analytics",
|
"view_analytics",
|
||||||
"export_reports",
|
"export_reports",
|
||||||
"create_vendor",
|
"create_vendor",
|
||||||
|
"manage_delivery_locations",
|
||||||
],
|
],
|
||||||
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
AUDITOR: ["view_own_pos", "view_all_pos", "view_analytics", "export_reports"],
|
||||||
ADMIN: [
|
ADMIN: [
|
||||||
|
|
@ -113,6 +116,7 @@ const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||||
"manage_vessels_accounts",
|
"manage_vessels_accounts",
|
||||||
"manage_products",
|
"manage_products",
|
||||||
"manage_sites",
|
"manage_sites",
|
||||||
|
"manage_delivery_locations",
|
||||||
],
|
],
|
||||||
SITE_STAFF: [],
|
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())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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 {
|
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