feat: Cost Centre covers vessels and sites, vessel codes, Accounting Code rename, vessel-site assignment
- Undo Vessel→Cost Centre rename in admin (admin shows "Vessel Management" again) - Sidebar: "Cost Centres"→"Vessels", "Accounts"→"Accounting Codes" - PO forms (new/edit/import/manager-edit) now show both Vessels (with code) and Sites in the Cost Centre dropdown, encoded as v:<id> / s:<id> via a costCentreRef field - vesselId on PurchaseOrder is now nullable; siteId is set when a site is the cost centre - History, approvals, dashboard, my-orders, payments display vessel.name ?? site.name as Cost Centre - History and approvals cost centre filters use costCentreRef URL param supporting both types - Admin vessel form: adds Site assignment dropdown - Admin accounts: renamed to "Accounting Code" throughout (pages, forms, sidebar) - PO detail and exports: "Account" label renamed to "Accounting Code" - Site detail: "Assigned Vessels (Cost Centres)" heading; vessel detail breadcrumb fixed - Create PO links from vessel/site detail use ?costCentreRef= param - Export routes handle costCentreRef filter param (with legacy vesselId fallback) - DB migration: ALTER TABLE PurchaseOrder ALTER COLUMN vesselId DROP NOT NULL - CLAUDE.md updated with Cost Centre Model documentation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3f3e1e6423
commit
cc7251e6b7
39 changed files with 333 additions and 168 deletions
|
|
@ -72,6 +72,18 @@ Every status change is validated against the state machine and recorded as a `PO
|
||||||
- `components/po/` — PO-specific components (line items editor, status badge, etc.)
|
- `components/po/` — PO-specific components (line items editor, status badge, etc.)
|
||||||
- `tests/integration/helpers.ts` — `makeSession()`, `makePoForm()`, `fd()` for integration test setup
|
- `tests/integration/helpers.ts` — `makeSession()`, `makePoForm()`, `fd()` for integration test setup
|
||||||
|
|
||||||
|
### Cost Centre Model
|
||||||
|
|
||||||
|
A PO's "cost centre" is either a **Vessel** or a **Site**. `PurchaseOrder` has both `vesselId String?` (nullable) and `siteId String?` — exactly one is set.
|
||||||
|
|
||||||
|
**Form encoding:** All PO creation/edit forms use a `costCentreRef` field with values `v:<vesselId>` (vessel) or `s:<siteId>` (site). Server actions parse this to set the correct FK.
|
||||||
|
|
||||||
|
**Display pattern:** `po.vessel?.name ?? po.site?.name ?? "—"` everywhere a cost centre name is shown.
|
||||||
|
|
||||||
|
**URL pre-select:** `/po/new?costCentreRef=v:<id>` or `?costCentreRef=s:<id>`.
|
||||||
|
|
||||||
|
**Terminology:** Admin pages use the real entity names (Vessel Management, Sites). PO-facing pages use "Cost Centre" for the combined concept. Budget heads are labelled "Accounting Code" (not "Account").
|
||||||
|
|
||||||
### GST Calculation
|
### GST Calculation
|
||||||
|
|
||||||
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.
|
`totalAmount = sum(quantity × unitPrice × (1 + gstRate))` for each line item. The `gstRate` is stored as a decimal on `POLineItem` (e.g., `0.18` = 18%). This applies in Server Actions when computing `totalPrice` per line and the PO `totalAmount`.
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ function AccountFormFields({ account, suggestedCode }: { account?: AccountRow; s
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Code *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Accounting Code *</label>
|
||||||
<input name="code" defaultValue={account?.code ?? suggestedCode} required
|
<input name="code" defaultValue={account?.code ?? suggestedCode} required
|
||||||
className="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"
|
className="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"
|
||||||
placeholder="e.g. ACC-001" />
|
placeholder="e.g. ACC-001" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Account Name *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
||||||
<input name="name" defaultValue={account?.name} required
|
<input name="name" defaultValue={account?.name} required
|
||||||
className="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" />
|
className="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" />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,9 +57,9 @@ export function AddAccountButton({ suggestedCode }: { suggestedCode?: string })
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setOpen(true)}
|
<button onClick={() => setOpen(true)}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
||||||
+ Add Account
|
+ Add Accounting Code
|
||||||
</button>
|
</button>
|
||||||
<AdminDialog title="Add Account" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Add Accounting Code" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<AccountFormFields suggestedCode={suggestedCode} />
|
<AccountFormFields suggestedCode={suggestedCode} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
|
@ -70,7 +70,7 @@ export function AddAccountButton({ suggestedCode }: { suggestedCode?: string })
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={pending}
|
<button type="submit" disabled={pending}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
{pending ? "Creating…" : "Create Account"}
|
{pending ? "Creating…" : "Create Accounting Code"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -116,7 +116,7 @@ export function EditAccountButton({
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<AdminDialog title="Edit Account" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Edit Accounting Code" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<AccountFormFields account={account} />
|
<AccountFormFields account={account} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
|
|
|
||||||
|
|
@ -93,14 +93,14 @@ export function AccountsTable({
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Account Management</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Accounting Code Management</h1>
|
||||||
<AddAccountButton suggestedCode={suggestedCode} />
|
<AddAccountButton suggestedCode={suggestedCode} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TableControls
|
<TableControls
|
||||||
search={search}
|
search={search}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
searchPlaceholder="Search accounts…"
|
searchPlaceholder="Search accounting codes…"
|
||||||
chips={CHIPS}
|
chips={CHIPS}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
onToggleFilter={toggleFilter}
|
onToggleFilter={toggleFilter}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { nextId } from "@/lib/id-generators";
|
||||||
import { AccountsTable } from "./accounts-table";
|
import { AccountsTable } from "./accounts-table";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Account Management" };
|
export const metadata: Metadata = { title: "Accounting Code Management" };
|
||||||
|
|
||||||
export default async function AdminAccountsPage() {
|
export default async function AdminAccountsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export default async function SiteDetailPage({ params }: Props) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Link href={`/po/new?siteId=${site.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
<Link href={`/po/new?costCentreRef=s:${site.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||||
+ Create PO
|
+ Create PO
|
||||||
</Link>
|
</Link>
|
||||||
{canEdit && <EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />}
|
{canEdit && <EditSiteButton site={{ id: site.id, name: site.name, code: site.code, address: site.address, latitude: site.latitude, longitude: site.longitude, isActive: site.isActive }} />}
|
||||||
|
|
@ -110,7 +110,7 @@ export default async function SiteDetailPage({ params }: Props) {
|
||||||
{/* Summary cards */}
|
{/* Summary cards */}
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
||||||
<p className="text-xs text-neutral-500 mb-1">Assigned Cost Centres</p>
|
<p className="text-xs text-neutral-500 mb-1">Assigned Vessels</p>
|
||||||
<p className="text-2xl font-semibold text-neutral-900">{site.vessels.length}</p>
|
<p className="text-2xl font-semibold text-neutral-900">{site.vessels.length}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
||||||
|
|
@ -170,7 +170,7 @@ export default async function SiteDetailPage({ params }: Props) {
|
||||||
{/* Assigned vessels */}
|
{/* Assigned vessels */}
|
||||||
{site.vessels.length > 0 && (
|
{site.vessels.length > 0 && (
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
||||||
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Assigned Vessels</h2>
|
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Assigned Vessels (Cost Centres)</h2>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{site.vessels.map((v) => (
|
{site.vessels.map((v) => (
|
||||||
<Link key={v.id} href={`/admin/vessels/${v.id}`}
|
<Link key={v.id} href={`/admin/vessels/${v.id}`}
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ function SiteActionsMenu({ site }: { site: SiteRow }) {
|
||||||
open={toggleOpen}
|
open={toggleOpen}
|
||||||
onOpenChange={setToggleOpen}
|
onOpenChange={setToggleOpen}
|
||||||
title={site.isActive ? `Deactivate ${site.name}?` : `Activate ${site.name}?`}
|
title={site.isActive ? `Deactivate ${site.name}?` : `Activate ${site.name}?`}
|
||||||
description={site.isActive ? `${site.name} will be hidden from cost centre selections.` : `${site.name} will become available for cost centre selections.`}
|
description={site.isActive ? `${site.name} will be deactivated.` : `${site.name} will become active.`}
|
||||||
confirmLabel={site.isActive ? "Deactivate" : "Activate"}
|
confirmLabel={site.isActive ? "Deactivate" : "Activate"}
|
||||||
onConfirm={() => toggleSiteActive(site.id)}
|
onConfirm={() => toggleSiteActive(site.id)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -123,7 +123,7 @@ export function SitesTable({
|
||||||
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Name</SortableTh>
|
<SortableTh sortKey="name" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Name</SortableTh>
|
||||||
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Code</SortableTh>
|
<SortableTh sortKey="code" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Code</SortableTh>
|
||||||
<SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Address</SortableTh>
|
<SortableTh sortKey="address" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Address</SortableTh>
|
||||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Cost Centres</th>
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">Vessels</th>
|
||||||
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
|
<th className="px-4 py-3 text-right font-medium text-neutral-600">Items tracked</th>
|
||||||
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
|
<th className="px-4 py-3 text-left font-medium text-neutral-600">Location</th>
|
||||||
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Status</SortableTh>
|
<SortableTh sortKey="isActive" activeSortKey={sortKey as string | null} sortDir={sortDir} onSort={(k) => toggleSort(k as keyof SiteRow)}>Status</SortableTh>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ interface Props { params: Promise<{ id: string }> }
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const v = await db.vessel.findUnique({ where: { id }, select: { name: true } });
|
const v = await db.vessel.findUnique({ where: { id }, select: { name: true } });
|
||||||
return { title: v?.name ?? "Cost Centre Detail" };
|
return { title: v?.name ?? "Vessel Detail" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function VesselDetailPage({ params }: Props) {
|
export default async function VesselDetailPage({ params }: Props) {
|
||||||
|
|
@ -47,7 +47,7 @@ export default async function VesselDetailPage({ params }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-5xl space-y-6">
|
<div className="max-w-5xl space-y-6">
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
||||||
<Link href="/admin/vessels" className="hover:text-neutral-700">Cost Centres</Link>
|
<Link href="/admin/vessels" className="hover:text-neutral-700">Vessels</Link>
|
||||||
<span>/</span>
|
<span>/</span>
|
||||||
<span className="text-neutral-900 font-medium">{vessel.name}</span>
|
<span className="text-neutral-900 font-medium">{vessel.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -66,7 +66,7 @@ export default async function VesselDetailPage({ params }: Props) {
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/po/new?vesselId=${vessel.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
<Link href={`/po/new?costCentreRef=v:${vessel.id}`} className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700">
|
||||||
+ Create PO
|
+ Create PO
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ type ActionResult = { ok: true } | { error: string };
|
||||||
|
|
||||||
const vesselSchema = z.object({
|
const vesselSchema = z.object({
|
||||||
name: z.string().min(1, "Vessel name is required"),
|
name: z.string().min(1, "Vessel name is required"),
|
||||||
|
siteId: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function createVessel(formData: FormData): Promise<ActionResult> {
|
export async function createVessel(formData: FormData): Promise<ActionResult> {
|
||||||
|
|
@ -21,13 +22,14 @@ export async function createVessel(formData: FormData): Promise<ActionResult> {
|
||||||
|
|
||||||
const parsed = vesselSchema.safeParse({
|
const parsed = vesselSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
});
|
});
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
|
||||||
const existingCodes = await db.vessel.findMany({ select: { code: true } });
|
const existingCodes = await db.vessel.findMany({ select: { code: true } });
|
||||||
const code = nextId("SITE", existingCodes.map((v) => v.code));
|
const code = nextId("SITE", existingCodes.map((v) => v.code));
|
||||||
|
|
||||||
await db.vessel.create({ data: { name: parsed.data.name, code } });
|
await db.vessel.create({ data: { name: parsed.data.name, code, siteId: parsed.data.siteId ?? null } });
|
||||||
revalidatePath("/admin/vessels");
|
revalidatePath("/admin/vessels");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
@ -43,10 +45,11 @@ export async function updateVessel(formData: FormData): Promise<ActionResult> {
|
||||||
|
|
||||||
const parsed = vesselSchema.safeParse({
|
const parsed = vesselSchema.safeParse({
|
||||||
name: formData.get("name"),
|
name: formData.get("name"),
|
||||||
|
siteId: (formData.get("siteId") as string) || undefined,
|
||||||
});
|
});
|
||||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||||
|
|
||||||
await db.vessel.update({ where: { id }, data: { name: parsed.data.name } });
|
await db.vessel.update({ where: { id }, data: { name: parsed.data.name, siteId: parsed.data.siteId ?? null } });
|
||||||
revalidatePath("/admin/vessels");
|
revalidatePath("/admin/vessels");
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
|
||||||
import { VesselsTable } from "./vessels-table";
|
import { VesselsTable } from "./vessels-table";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Cost Centre Management" };
|
export const metadata: Metadata = { title: "Vessel Management" };
|
||||||
|
|
||||||
export default async function AdminVesselsPage() {
|
export default async function AdminVesselsPage() {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
|
|
@ -13,10 +13,13 @@ export default async function AdminVesselsPage() {
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
if (!hasPermission(session.user.role, "manage_vessels_accounts")) redirect("/dashboard");
|
||||||
|
|
||||||
const vessels = await db.vessel.findMany({
|
const [vessels, sites] = await Promise.all([
|
||||||
orderBy: { name: "asc" },
|
db.vessel.findMany({
|
||||||
include: { site: { select: { name: true } } },
|
orderBy: { name: "asc" },
|
||||||
});
|
include: { site: { select: { name: true } } },
|
||||||
|
}),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VesselsTable
|
<VesselsTable
|
||||||
|
|
@ -24,9 +27,11 @@ export default async function AdminVesselsPage() {
|
||||||
id: v.id,
|
id: v.id,
|
||||||
code: v.code,
|
code: v.code,
|
||||||
name: v.name,
|
name: v.name,
|
||||||
|
siteId: v.siteId ?? null,
|
||||||
siteName: v.site?.name ?? null,
|
siteName: v.site?.name ?? null,
|
||||||
isActive: v.isActive,
|
isActive: v.isActive,
|
||||||
}))}
|
}))}
|
||||||
|
sites={sites}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,19 @@ import { useRouter } from "next/navigation";
|
||||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||||
import { createVessel, updateVessel, toggleVesselActive } from "./actions";
|
import { createVessel, updateVessel, toggleVesselActive } from "./actions";
|
||||||
|
|
||||||
|
type SiteOption = { id: string; name: string };
|
||||||
|
|
||||||
type VesselRow = {
|
type VesselRow = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
|
siteId: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function VesselFormFields({ vessel }: { vessel?: VesselRow }) {
|
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";
|
||||||
|
|
||||||
|
function VesselFormFields({ vessel, sites }: { vessel?: VesselRow; sites: SiteOption[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{vessel && (
|
{vessel && (
|
||||||
|
|
@ -22,15 +27,25 @@ function VesselFormFields({ vessel }: { vessel?: VesselRow }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Cost Centre Name *</label>
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Vessel Name *</label>
|
||||||
<input name="name" defaultValue={vessel?.name} required
|
<input name="name" defaultValue={vessel?.name} required className={INPUT} />
|
||||||
className="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" />
|
|
||||||
</div>
|
</div>
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-neutral-700 mb-1">Assigned Site</label>
|
||||||
|
<select name="siteId" defaultValue={vessel?.siteId ?? ""} className={INPUT}>
|
||||||
|
<option value="">— No site —</option>
|
||||||
|
{sites.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>{s.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AddVesselButton() {
|
export function AddVesselButton({ sites }: { sites: SiteOption[] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
|
|
@ -49,11 +64,11 @@ export function AddVesselButton() {
|
||||||
<>
|
<>
|
||||||
<button onClick={() => setOpen(true)}
|
<button onClick={() => setOpen(true)}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors">
|
||||||
+ Add Cost Centre
|
+ Add Vessel
|
||||||
</button>
|
</button>
|
||||||
<AdminDialog title="Add Cost Centre" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Add Vessel" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<VesselFormFields />
|
<VesselFormFields sites={sites} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
<button type="button" onClick={() => setOpen(false)}
|
<button type="button" onClick={() => setOpen(false)}
|
||||||
|
|
@ -62,7 +77,7 @@ export function AddVesselButton() {
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" disabled={pending}
|
<button type="submit" disabled={pending}
|
||||||
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
className="rounded-lg bg-primary-600 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60">
|
||||||
{pending ? "Creating…" : "Create Cost Centre"}
|
{pending ? "Creating…" : "Create Vessel"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
@ -73,10 +88,12 @@ export function AddVesselButton() {
|
||||||
|
|
||||||
export function EditVesselButton({
|
export function EditVesselButton({
|
||||||
vessel,
|
vessel,
|
||||||
|
sites,
|
||||||
open: controlledOpen,
|
open: controlledOpen,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: {
|
}: {
|
||||||
vessel: VesselRow;
|
vessel: VesselRow;
|
||||||
|
sites: SiteOption[];
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
onOpenChange?: (v: boolean) => void;
|
onOpenChange?: (v: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -108,9 +125,9 @@ export function EditVesselButton({
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<AdminDialog title="Edit Cost Centre" open={open} onClose={() => setOpen(false)}>
|
<AdminDialog title="Edit Vessel" open={open} onClose={() => setOpen(false)}>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<VesselFormFields vessel={vessel} />
|
<VesselFormFields vessel={vessel} sites={sites} />
|
||||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||||
<div className="flex justify-end gap-3 pt-1">
|
<div className="flex justify-end gap-3 pt-1">
|
||||||
<button type="button" onClick={() => setOpen(false)}
|
<button type="button" onClick={() => setOpen(false)}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,16 @@ export type VesselRow = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
siteId: string | null;
|
||||||
siteName: string | null;
|
siteName: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type SiteOption = { id: string; name: string };
|
||||||
|
|
||||||
const CHIPS = ["Active", "Inactive"];
|
const CHIPS = ["Active", "Inactive"];
|
||||||
|
|
||||||
function VesselActionsMenu({ vessel }: { vessel: VesselRow }) {
|
function VesselActionsMenu({ vessel, sites }: { vessel: VesselRow; sites: SiteOption[] }) {
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
const [toggleOpen, setToggleOpen] = useState(false);
|
const [toggleOpen, setToggleOpen] = useState(false);
|
||||||
|
|
@ -36,12 +39,8 @@ function VesselActionsMenu({ vessel }: { vessel: VesselRow }) {
|
||||||
</RowActionsMenu>
|
</RowActionsMenu>
|
||||||
|
|
||||||
<EditVesselButton
|
<EditVesselButton
|
||||||
vessel={{
|
vessel={{ id: vessel.id, name: vessel.name, code: vessel.code, siteId: vessel.siteId, isActive: vessel.isActive }}
|
||||||
id: vessel.id,
|
sites={sites}
|
||||||
name: vessel.name,
|
|
||||||
code: vessel.code,
|
|
||||||
isActive: vessel.isActive,
|
|
||||||
}}
|
|
||||||
open={editOpen}
|
open={editOpen}
|
||||||
onOpenChange={setEditOpen}
|
onOpenChange={setEditOpen}
|
||||||
/>
|
/>
|
||||||
|
|
@ -63,7 +62,7 @@ function VesselActionsMenu({ vessel }: { vessel: VesselRow }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
export function VesselsTable({ vessels, sites }: { vessels: VesselRow[]; sites: SiteOption[] }) {
|
||||||
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
const { search, setSearch, sortKey, sortDir, toggleSort, activeFilters, toggleFilter, filtered } =
|
||||||
useTableControls<VesselRow>({
|
useTableControls<VesselRow>({
|
||||||
rows: vessels,
|
rows: vessels,
|
||||||
|
|
@ -87,14 +86,14 @@ export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Cost Centre Management</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Vessel Management</h1>
|
||||||
<AddVesselButton />
|
<AddVesselButton sites={sites} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TableControls
|
<TableControls
|
||||||
search={search}
|
search={search}
|
||||||
onSearch={setSearch}
|
onSearch={setSearch}
|
||||||
searchPlaceholder="Search cost centres…"
|
searchPlaceholder="Search vessels…"
|
||||||
chips={CHIPS}
|
chips={CHIPS}
|
||||||
activeFilters={activeFilters}
|
activeFilters={activeFilters}
|
||||||
onToggleFilter={toggleFilter}
|
onToggleFilter={toggleFilter}
|
||||||
|
|
@ -115,7 +114,7 @@ export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
||||||
{filtered.length === 0 && (
|
{filtered.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
|
<td colSpan={5} className="px-4 py-8 text-center text-neutral-400">
|
||||||
No cost centres match your search.
|
No vessels match your search.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
|
|
@ -134,7 +133,7 @@ export function VesselsTable({ vessels }: { vessels: VesselRow[] }) {
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<VesselActionsMenu vessel={vessel} />
|
<VesselActionsMenu vessel={vessel} sites={sites} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ import { managerEditPo } from "./manager-po-edit-actions";
|
||||||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||||
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
import { TC_DEFAULTS, TC_FIXED_LINE } from "@/lib/validations/po";
|
||||||
import type { LineItemInput } from "@/lib/validations/po";
|
import type { LineItemInput } from "@/lib/validations/po";
|
||||||
import type { Vessel, Account, Vendor, PurchaseOrder } from "@prisma/client";
|
import type { Account, Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
|
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
|
|
||||||
type SerializedLineItem = {
|
type SerializedLineItem = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -26,14 +27,15 @@ type SerializedLineItem = {
|
||||||
type PoFull = Omit<PurchaseOrder, "totalAmount"> & {
|
type PoFull = Omit<PurchaseOrder, "totalAmount"> & {
|
||||||
totalAmount: number;
|
totalAmount: number;
|
||||||
lineItems: SerializedLineItem[];
|
lineItems: SerializedLineItem[];
|
||||||
vessel: { id: string; name: string };
|
vessel: { id: string; name: string } | null;
|
||||||
account: { id: string; name: string; code: string };
|
account: { id: string; name: string; code: string };
|
||||||
vendor: { id: string; name: string; vendorId: string | null } | null;
|
vendor: { id: string; name: string; vendorId: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
po: PoFull;
|
po: PoFull;
|
||||||
vessels: Vessel[];
|
costCentres: CostCentreOption[];
|
||||||
|
initialCostCentreRef: string;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
}
|
}
|
||||||
|
|
@ -42,7 +44,7 @@ const INPUT =
|
||||||
"w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-400/30 placeholder:text-neutral-400";
|
"w-full rounded-lg border border-amber-300 bg-amber-50 px-3 py-2 text-sm focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-400/30 placeholder:text-neutral-400";
|
||||||
const LABEL = "block text-xs font-semibold text-amber-800 mb-1";
|
const LABEL = "block text-xs font-semibold text-amber-800 mb-1";
|
||||||
|
|
||||||
export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
|
export function ManagerEditPoForm({ po, costCentres, initialCostCentreRef, accounts, vendors }: 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);
|
||||||
|
|
@ -151,12 +153,21 @@ export function ManagerEditPoForm({ po, vessels, accounts, vendors }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
|
<label className={LABEL}>Cost Centre <span className="text-danger">*</span></label>
|
||||||
<select name="vesselId" required defaultValue={po.vesselId} className={INPUT}>
|
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT}>
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
<optgroup label="Vessels">
|
||||||
|
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Sites">
|
||||||
|
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className={LABEL}>Account <span className="text-danger">*</span></label>
|
<label className={LABEL}>Accounting Code <span className="text-danger">*</span></label>
|
||||||
<select name="accountId" required defaultValue={po.accountId} className={INPUT}>
|
<select name="accountId" required defaultValue={po.accountId} className={INPUT}>
|
||||||
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
{accounts.map((a) => <option key={a.id} value={a.id}>{a.name} ({a.code})</option>)}
|
||||||
</select>
|
</select>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ export async function managerEditPo(
|
||||||
|
|
||||||
const parsed = createPoSchema.safeParse({
|
const parsed = createPoSchema.safeParse({
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
vesselId: formData.get("vesselId"),
|
costCentreRef: formData.get("costCentreRef"),
|
||||||
accountId: formData.get("accountId"),
|
accountId: formData.get("accountId"),
|
||||||
projectCode: formData.get("projectCode") || undefined,
|
projectCode: formData.get("projectCode") || undefined,
|
||||||
dateRequired: formData.get("dateRequired") || undefined,
|
dateRequired: formData.get("dateRequired") || undefined,
|
||||||
|
|
@ -67,6 +67,8 @@ export async function managerEditPo(
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
|
const newVesselId = data.costCentreRef.startsWith("v:") ? data.costCentreRef.slice(2) : null;
|
||||||
|
const newCostCentreSiteId = data.costCentreRef.startsWith("s:") ? data.costCentreRef.slice(2) : null;
|
||||||
const newTotal = data.lineItems.reduce(
|
const newTotal = data.lineItems.reduce(
|
||||||
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
||||||
0
|
0
|
||||||
|
|
@ -83,7 +85,7 @@ export async function managerEditPo(
|
||||||
};
|
};
|
||||||
const original = {
|
const original = {
|
||||||
title: po.title,
|
title: po.title,
|
||||||
vesselId: po.vesselId,
|
vesselId: po.vesselId ?? po.siteId ?? "",
|
||||||
accountId: po.accountId,
|
accountId: po.accountId,
|
||||||
vendorId: po.vendorId,
|
vendorId: po.vendorId,
|
||||||
projectCode: po.projectCode,
|
projectCode: po.projectCode,
|
||||||
|
|
@ -112,7 +114,8 @@ export async function managerEditPo(
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
vesselId: data.vesselId,
|
vesselId: newVesselId,
|
||||||
|
siteId: newCostCentreSiteId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
vendorId: data.vendorId ?? null,
|
vendorId: data.vendorId ?? null,
|
||||||
projectCode: data.projectCode ?? null,
|
projectCode: data.projectCode ?? null,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { notFound, redirect } from "next/navigation";
|
||||||
import { ApprovalActions } from "./approval-actions";
|
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 type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -28,12 +29,13 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
});
|
});
|
||||||
const hasSignature = !!(currentUser?.signatureKey);
|
const hasSignature = !!(currentUser?.signatureKey);
|
||||||
|
|
||||||
const [po, vessels, accounts, vendors] = await Promise.all([
|
const [po, vessels, sites, accounts, vendors] = await Promise.all([
|
||||||
db.purchaseOrder.findUnique({
|
db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
submitter: true,
|
submitter: true,
|
||||||
vessel: true,
|
vessel: true,
|
||||||
|
site: { select: { id: true, name: true } },
|
||||||
account: true,
|
account: true,
|
||||||
vendor: true,
|
vendor: true,
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
|
|
@ -42,7 +44,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
receipt: true,
|
receipt: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
@ -50,6 +53,12 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
if (!po) notFound();
|
if (!po) notFound();
|
||||||
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
if (po.status !== "MGR_REVIEW") redirect(`/po/${id}`);
|
||||||
|
|
||||||
|
const costCentres: CostCentreOption[] = [
|
||||||
|
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code} — ${v.name}`, group: "Vessels" as const })),
|
||||||
|
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code} — ${s.name}`, group: "Sites" as const })),
|
||||||
|
];
|
||||||
|
const initialCostCentreRef = po ? (po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "") : "";
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
totalAmount: po.totalAmount.toNumber(),
|
totalAmount: po.totalAmount.toNumber(),
|
||||||
|
|
@ -86,7 +95,8 @@ export default async function ApprovalDetailPage({ params }: Props) {
|
||||||
<div className="hidden md:block">
|
<div className="hidden md:block">
|
||||||
<ManagerEditPoForm
|
<ManagerEditPoForm
|
||||||
po={serializedPo}
|
po={serializedPo}
|
||||||
vessels={vessels}
|
costCentres={costCentres}
|
||||||
|
initialCostCentreRef={initialCostCentreRef}
|
||||||
accounts={accounts}
|
accounts={accounts}
|
||||||
vendors={vendors}
|
vendors={vendors}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -4,33 +4,33 @@ import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: { id: string; name: string }[];
|
costCentres: { ref: string; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ApprovalsSearch({ vessels }: Props) {
|
export function ApprovalsSearch({ costCentres }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
const [q, setQ] = useState(sp.get("q") ?? "");
|
const [q, setQ] = useState(sp.get("q") ?? "");
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [costCentreRef, setCostCentreRef] = useState(sp.get("costCentreRef") ?? "");
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (q.trim()) params.set("q", q.trim());
|
if (q.trim()) params.set("q", q.trim());
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (costCentreRef) params.set("costCentreRef", costCentreRef);
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
router.push(`/approvals?${params.toString()}`);
|
router.push(`/approvals?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setQ(""); setVesselId(""); setDateFrom(""); setDateTo("");
|
setQ(""); setCostCentreRef(""); setDateFrom(""); setDateTo("");
|
||||||
router.push("/approvals");
|
router.push("/approvals");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = q || vesselId || dateFrom || dateTo;
|
const hasFilters = q || costCentreRef || dateFrom || dateTo;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
|
|
@ -44,10 +44,12 @@ export function ApprovalsSearch({ vessels }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
||||||
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
<select value={costCentreRef} onChange={(e) => setCostCentreRef(e.target.value)}
|
||||||
className="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">
|
className="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">
|
||||||
<option value="">All cost centres</option>
|
<option value="">All cost centres</option>
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{costCentres.map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.name}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ export const metadata: Metadata = { title: "Approvals" };
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
q?: string;
|
q?: string;
|
||||||
vesselId?: string;
|
costCentreRef?: string;
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
}>;
|
}>;
|
||||||
|
|
@ -25,7 +25,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard");
|
if (!hasPermission(session.user.role, "approve_po")) redirect("/dashboard");
|
||||||
|
|
||||||
const { q, vesselId, dateFrom } = await searchParams;
|
const { q, costCentreRef, dateFrom } = await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {
|
||||||
status: "MGR_REVIEW",
|
status: "MGR_REVIEW",
|
||||||
|
|
@ -38,18 +38,27 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
||||||
{ title: { contains: q.trim(), mode: "insensitive" } },
|
{ title: { contains: q.trim(), mode: "insensitive" } },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (costCentreRef) {
|
||||||
|
if (costCentreRef.startsWith("v:")) where.vesselId = costCentreRef.slice(2);
|
||||||
|
else if (costCentreRef.startsWith("s:")) where.siteId = costCentreRef.slice(2);
|
||||||
|
}
|
||||||
if (dateFrom) where.submittedAt = { gte: new Date(dateFrom) };
|
if (dateFrom) where.submittedAt = { gte: new Date(dateFrom) };
|
||||||
|
|
||||||
const [pending, vessels] = await Promise.all([
|
const [pending, vessels, sites] = await Promise.all([
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
include: { submitter: true, vessel: true, account: true },
|
include: { submitter: true, vessel: true, site: { select: { name: true } }, account: true },
|
||||||
orderBy: { submittedAt: "asc" },
|
orderBy: { submittedAt: "asc" },
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.site.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const costCentres = [
|
||||||
|
...vessels.map((v) => ({ ref: `v:${v.id}`, name: v.name })),
|
||||||
|
...sites.map((s) => ({ ref: `s:${s.id}`, name: s.name })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|
@ -60,7 +69,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<ApprovalsSearch vessels={vessels} />
|
<ApprovalsSearch costCentres={costCentres} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
{pending.length === 0 ? (
|
{pending.length === 0 ? (
|
||||||
|
|
@ -89,7 +98,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
||||||
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{po.poNumber}</td>
|
<td className="px-4 py-3 font-mono text-xs text-neutral-600">{po.poNumber}</td>
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-sm">
|
<td className="px-4 py-3 text-right font-mono text-sm">
|
||||||
{formatCurrency(Number(po.totalAmount), po.currency)}
|
{formatCurrency(Number(po.totalAmount), po.currency)}
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -121,7 +130,7 @@ export default async function ApprovalsPage({ searchParams }: Props) {
|
||||||
<p className="font-semibold text-neutral-900 leading-snug mb-2">{po.title}</p>
|
<p className="font-semibold text-neutral-900 leading-snug mb-2">{po.title}</p>
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-neutral-500 truncate max-w-[55%]">
|
<span className="text-neutral-500 truncate max-w-[55%]">
|
||||||
{po.submitter.name} · {po.vessel.name}
|
{po.submitter.name} · {po.vessel?.name ?? po.site?.name ?? "—"}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono font-semibold text-neutral-900 shrink-0">
|
<span className="font-mono font-semibold text-neutral-900 shrink-0">
|
||||||
{formatCurrency(Number(po.totalAmount), po.currency)}
|
{formatCurrency(Number(po.totalAmount), po.currency)}
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,7 @@ async function ManagerDashboard() {
|
||||||
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED"] } },
|
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED"] } },
|
||||||
orderBy: { approvedAt: "desc" },
|
orderBy: { approvedAt: "desc" },
|
||||||
take: 8,
|
take: 8,
|
||||||
select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, approvedAt: true, vessel: { select: { name: true } } },
|
select: { id: true, poNumber: true, title: true, status: true, totalAmount: true, approvedAt: true, vessel: { select: { name: true } }, site: { select: { name: true } } },
|
||||||
}),
|
}),
|
||||||
db.purchaseOrder.groupBy({
|
db.purchaseOrder.groupBy({
|
||||||
by: ["vesselId"],
|
by: ["vesselId"],
|
||||||
|
|
@ -138,7 +138,7 @@ async function ManagerDashboard() {
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const vesselIds = vesselBreakdown.map((r) => r.vesselId);
|
const vesselIds = vesselBreakdown.map((r) => r.vesselId).filter((id): id is string => id !== null);
|
||||||
const vessels = await db.vessel.findMany({ where: { id: { in: vesselIds } }, select: { id: true, name: true } });
|
const vessels = await db.vessel.findMany({ where: { id: { in: vesselIds } }, select: { id: true, name: true } });
|
||||||
const vesselMap = Object.fromEntries(vessels.map((v) => [v.id, v.name]));
|
const vesselMap = Object.fromEntries(vessels.map((v) => [v.id, v.name]));
|
||||||
|
|
||||||
|
|
@ -162,10 +162,12 @@ async function ManagerDashboard() {
|
||||||
return { month: label, amount };
|
return { month: label, amount };
|
||||||
});
|
});
|
||||||
|
|
||||||
const vesselChartData = vesselBreakdown.map((row) => ({
|
const vesselChartData = vesselBreakdown
|
||||||
name: vesselMap[row.vesselId] ?? "Unknown",
|
.filter((row) => row.vesselId !== null)
|
||||||
amount: Number(row._sum.totalAmount ?? 0),
|
.map((row) => ({
|
||||||
}));
|
name: vesselMap[row.vesselId!] ?? "Unknown",
|
||||||
|
amount: Number(row._sum.totalAmount ?? 0),
|
||||||
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -204,7 +206,7 @@ async function ManagerDashboard() {
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-900 max-w-xs truncate">{po.title}</td>
|
<td className="px-4 py-3 text-neutral-900 max-w-xs truncate">{po.title}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<PoStatusBadge status={po.status} />
|
<PoStatusBadge status={po.status} />
|
||||||
</td>
|
</td>
|
||||||
|
|
|
||||||
|
|
@ -18,33 +18,33 @@ const STATUSES = [
|
||||||
];
|
];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: { id: string; name: string }[];
|
costCentres: { ref: string; name: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HistoryFilters({ vessels }: Props) {
|
export function HistoryFilters({ costCentres }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
const [dateFrom, setDateFrom] = useState(sp.get("dateFrom") ?? "");
|
||||||
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
const [dateTo, setDateTo] = useState(sp.get("dateTo") ?? "");
|
||||||
const [vesselId, setVesselId] = useState(sp.get("vesselId") ?? "");
|
const [costCentreRef, setCostCentreRef] = useState(sp.get("costCentreRef") ?? "");
|
||||||
const [status, setStatus] = useState(sp.get("status") ?? "");
|
const [status, setStatus] = useState(sp.get("status") ?? "");
|
||||||
|
|
||||||
function apply() {
|
function apply() {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (dateFrom) params.set("dateFrom", dateFrom);
|
if (dateFrom) params.set("dateFrom", dateFrom);
|
||||||
if (dateTo) params.set("dateTo", dateTo);
|
if (dateTo) params.set("dateTo", dateTo);
|
||||||
if (vesselId) params.set("vesselId", vesselId);
|
if (costCentreRef) params.set("costCentreRef", costCentreRef);
|
||||||
if (status) params.set("status", status);
|
if (status) params.set("status", status);
|
||||||
router.push(`/history?${params.toString()}`);
|
router.push(`/history?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDateFrom(""); setDateTo(""); setVesselId(""); setStatus("");
|
setDateFrom(""); setDateTo(""); setCostCentreRef(""); setStatus("");
|
||||||
router.push("/history");
|
router.push("/history");
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = dateFrom || dateTo || vesselId || status;
|
const hasFilters = dateFrom || dateTo || costCentreRef || status;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
<div className="mb-4 rounded-lg border border-neutral-200 bg-white p-4">
|
||||||
|
|
@ -61,10 +61,12 @@ export function HistoryFilters({ vessels }: Props) {
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
<label className="block text-xs font-medium text-neutral-600 mb-1">Cost Centre</label>
|
||||||
<select value={vesselId} onChange={(e) => setVesselId(e.target.value)}
|
<select value={costCentreRef} onChange={(e) => setCostCentreRef(e.target.value)}
|
||||||
className="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">
|
className="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">
|
||||||
<option value="">All cost centres</option>
|
<option value="">All cost centres</option>
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
{costCentres.map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.name}</option>
|
||||||
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ interface Props {
|
||||||
searchParams: Promise<{
|
searchParams: Promise<{
|
||||||
dateFrom?: string;
|
dateFrom?: string;
|
||||||
dateTo?: string;
|
dateTo?: string;
|
||||||
vesselId?: string;
|
costCentreRef?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
@ -27,7 +27,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
|
|
||||||
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
if (!hasPermission(session.user.role, "export_reports")) redirect("/dashboard");
|
||||||
|
|
||||||
const { dateFrom, dateTo, vesselId, status } = await searchParams;
|
const { dateFrom, dateTo, costCentreRef, status } = await searchParams;
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
if (dateFrom || dateTo) {
|
if (dateFrom || dateTo) {
|
||||||
|
|
@ -40,23 +40,32 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (costCentreRef) {
|
||||||
|
if (costCentreRef.startsWith("v:")) where.vesselId = costCentreRef.slice(2);
|
||||||
|
else if (costCentreRef.startsWith("s:")) where.siteId = costCentreRef.slice(2);
|
||||||
|
}
|
||||||
if (status) where.status = status as POStatus;
|
if (status) where.status = status as POStatus;
|
||||||
|
|
||||||
const [orders, vessels] = await Promise.all([
|
const [orders, vessels, sites] = await Promise.all([
|
||||||
db.purchaseOrder.findMany({
|
db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
include: { submitter: true, vessel: true, account: true },
|
include: { submitter: true, vessel: true, site: { select: { name: true } }, account: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 200,
|
take: 200,
|
||||||
}),
|
}),
|
||||||
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
db.vessel.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
|
db.site.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const costCentres = [
|
||||||
|
...vessels.map((v) => ({ ref: `v:${v.id}`, name: v.name })),
|
||||||
|
...sites.map((s) => ({ ref: `s:${s.id}`, name: s.name })),
|
||||||
|
];
|
||||||
|
|
||||||
const exportParams = new URLSearchParams({ format: "csv" });
|
const exportParams = new URLSearchParams({ format: "csv" });
|
||||||
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
if (dateFrom) exportParams.set("dateFrom", dateFrom);
|
||||||
if (dateTo) exportParams.set("dateTo", dateTo);
|
if (dateTo) exportParams.set("dateTo", dateTo);
|
||||||
if (vesselId) exportParams.set("vesselId", vesselId);
|
if (costCentreRef) exportParams.set("costCentreRef", costCentreRef);
|
||||||
if (status) exportParams.set("status", status);
|
if (status) exportParams.set("status", status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -82,7 +91,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<HistoryFilters vessels={vessels} />
|
<HistoryFilters costCentres={costCentres} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||||
|
|
@ -107,7 +116,7 @@ export default async function HistoryPage({ searchParams }: Props) {
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">{po.title}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<PoStatusBadge status={po.status} />
|
<PoStatusBadge status={po.status} />
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ export default async function MyOrdersPage() {
|
||||||
orderBy: { updatedAt: "desc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
vessel: { select: { name: true } },
|
vessel: { select: { name: true } },
|
||||||
|
site: { select: { name: true } },
|
||||||
account: { select: { name: true, code: true } },
|
account: { select: { name: true, code: true } },
|
||||||
actions: {
|
actions: {
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -67,7 +68,8 @@ type PoRow = {
|
||||||
title: string;
|
title: string;
|
||||||
status: import("@prisma/client").POStatus;
|
status: import("@prisma/client").POStatus;
|
||||||
totalAmount: import("@prisma/client").Prisma.Decimal;
|
totalAmount: import("@prisma/client").Prisma.Decimal;
|
||||||
vessel: { name: string };
|
vessel: { name: string } | null;
|
||||||
|
site: { name: string } | null;
|
||||||
account: { name: string; code: string };
|
account: { name: string; code: string };
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
managerNote: string | null;
|
managerNote: string | null;
|
||||||
|
|
@ -109,7 +111,7 @@ function PoTable({ title, rows, className = "" }: { title: string; rows: PoRow[]
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td>
|
||||||
<td className="px-4 py-3"><PoStatusBadge status={po.status} /></td>
|
<td className="px-4 py-3"><PoStatusBadge status={po.status} /></td>
|
||||||
<td className="px-4 py-3 text-right font-mono text-xs">{formatCurrency(Number(po.totalAmount))}</td>
|
<td className="px-4 py-3 text-right font-mono text-xs">{formatCurrency(Number(po.totalAmount))}</td>
|
||||||
<td className="px-4 py-3 text-neutral-500">{formatDate(po.updatedAt)}</td>
|
<td className="px-4 py-3 text-neutral-500">{formatDate(po.updatedAt)}</td>
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ export default async function PaymentHistoryPage({ searchParams }: Props) {
|
||||||
include: {
|
include: {
|
||||||
submitter: true,
|
submitter: true,
|
||||||
vessel: true,
|
vessel: true,
|
||||||
|
site: { select: { name: true } },
|
||||||
account: true,
|
account: true,
|
||||||
vendor: true,
|
vendor: true,
|
||||||
},
|
},
|
||||||
|
|
@ -116,7 +117,7 @@ export default async function PaymentHistoryPage({ searchParams }: Props) {
|
||||||
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">
|
<td className="px-4 py-3 font-medium text-neutral-900 max-w-xs truncate">
|
||||||
{po.title}
|
{po.title}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vessel.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vessel?.name ?? po.site?.name ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.vendor?.name ?? "—"}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.vendor?.name ?? "—"}</td>
|
||||||
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
<td className="px-4 py-3 text-neutral-600">{po.submitter.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ export default async function PaymentsPage() {
|
||||||
|
|
||||||
const queue = await db.purchaseOrder.findMany({
|
const queue = await db.purchaseOrder.findMany({
|
||||||
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PARTIALLY_CLOSED"] } },
|
where: { status: { in: ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PARTIALLY_CLOSED"] } },
|
||||||
include: { submitter: true, vessel: true, account: true, vendor: true },
|
include: { submitter: true, vessel: true, site: { select: { name: true } }, account: true, vendor: true },
|
||||||
orderBy: { approvedAt: "asc" },
|
orderBy: { approvedAt: "asc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ export default async function PaymentsPage() {
|
||||||
</div>
|
</div>
|
||||||
<h3 className="font-medium text-neutral-900 truncate">{po.title}</h3>
|
<h3 className="font-medium text-neutral-900 truncate">{po.title}</h3>
|
||||||
<div className="mt-1 flex flex-wrap gap-3 text-sm text-neutral-500">
|
<div className="mt-1 flex flex-wrap gap-3 text-sm text-neutral-500">
|
||||||
<span>{po.vessel.name}</span>
|
<span>{po.vessel?.name ?? po.site?.name ?? "—"}</span>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
<span>{po.submitter.name}</span>
|
<span>{po.submitter.name}</span>
|
||||||
{po.vendor && (
|
{po.vendor && (
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ export async function updatePo(
|
||||||
|
|
||||||
const parsed = createPoSchema.safeParse({
|
const parsed = createPoSchema.safeParse({
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
vesselId: formData.get("vesselId"),
|
costCentreRef: formData.get("costCentreRef"),
|
||||||
accountId: formData.get("accountId"),
|
accountId: formData.get("accountId"),
|
||||||
projectCode: formData.get("projectCode") || undefined,
|
projectCode: formData.get("projectCode") || undefined,
|
||||||
dateRequired: formData.get("dateRequired") || undefined,
|
dateRequired: formData.get("dateRequired") || undefined,
|
||||||
|
|
@ -69,6 +69,8 @@ export async function updatePo(
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
|
const newVesselId = data.costCentreRef.startsWith("v:") ? data.costCentreRef.slice(2) : null;
|
||||||
|
const newCostCentreSiteId = data.costCentreRef.startsWith("s:") ? data.costCentreRef.slice(2) : null;
|
||||||
const total = data.lineItems.reduce(
|
const total = data.lineItems.reduce(
|
||||||
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
||||||
0
|
0
|
||||||
|
|
@ -100,6 +102,7 @@ export async function updatePo(
|
||||||
include: {
|
include: {
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
vessel: true,
|
vessel: true,
|
||||||
|
site: { select: { name: true } },
|
||||||
account: true,
|
account: true,
|
||||||
vendor: true,
|
vendor: true,
|
||||||
},
|
},
|
||||||
|
|
@ -117,8 +120,8 @@ export async function updatePo(
|
||||||
})),
|
})),
|
||||||
fields: {
|
fields: {
|
||||||
title: currentPo.title,
|
title: currentPo.title,
|
||||||
vessel: currentPo.vessel?.name ?? null,
|
vessel: currentPo.vessel?.name ?? currentPo.site?.name ?? null,
|
||||||
vesselId: currentPo.vesselId,
|
vesselId: currentPo.vesselId ?? currentPo.siteId ?? "",
|
||||||
account: `${currentPo.account.name} (${currentPo.account.code})`,
|
account: `${currentPo.account.name} (${currentPo.account.code})`,
|
||||||
accountId: currentPo.accountId,
|
accountId: currentPo.accountId,
|
||||||
vendor: currentPo.vendor?.name ?? null,
|
vendor: currentPo.vendor?.name ?? null,
|
||||||
|
|
@ -135,7 +138,8 @@ export async function updatePo(
|
||||||
where: { id: poId },
|
where: { id: poId },
|
||||||
data: {
|
data: {
|
||||||
title: data.title,
|
title: data.title,
|
||||||
vesselId: data.vesselId,
|
vesselId: newVesselId,
|
||||||
|
siteId: newCostCentreSiteId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
vendorId: data.vendorId ?? null,
|
vendorId: data.vendorId ?? null,
|
||||||
projectCode: data.projectCode ?? null,
|
projectCode: data.projectCode ?? null,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { updatePo } from "./actions";
|
import { updatePo } from "./actions";
|
||||||
import type { Vessel, Account, Vendor, PurchaseOrder } from "@prisma/client";
|
import type { Account, Vendor, PurchaseOrder } from "@prisma/client";
|
||||||
|
import type { CostCentreOption } 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 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";
|
||||||
|
|
@ -34,13 +35,14 @@ type PoWithItems = Omit<PurchaseOrder, "totalAmount"> & {
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
po: PoWithItems;
|
po: PoWithItems;
|
||||||
vessels: Vessel[];
|
costCentres: CostCentreOption[];
|
||||||
|
initialCostCentreRef: string;
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
managerNoteAuthor?: string | null;
|
managerNoteAuthor?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EditPoForm({ po, vessels, accounts, vendors, managerNoteAuthor }: Props) {
|
export function EditPoForm({ po, costCentres, initialCostCentreRef, accounts, vendors, 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) => ({
|
||||||
|
|
@ -128,15 +130,24 @@ export function EditPoForm({ po, vessels, accounts, vendors, managerNoteAuthor }
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Cost Centre <span className="text-danger">*</span>
|
Cost Centre <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="vesselId" required defaultValue={po.vesselId} className={INPUT_CLS}>
|
<select name="costCentreRef" required defaultValue={initialCostCentreRef} className={INPUT_CLS}>
|
||||||
<option value="">Select vessel…</option>
|
<option value="">Select cost centre…</option>
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
<optgroup label="Vessels">
|
||||||
|
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Sites">
|
||||||
|
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="text-sm font-medium text-neutral-700">
|
<label className="text-sm font-medium text-neutral-700">
|
||||||
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
|
{multiAccount ? "Default Accounting Code" : "Accounting Code"} <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
|
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
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 type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
@ -29,8 +30,9 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
po.submitterId === session.user.id || session.user.role === "SUPERUSER";
|
||||||
if (!canEdit) redirect(`/po/${id}`);
|
if (!canEdit) redirect(`/po/${id}`);
|
||||||
|
|
||||||
const [vessels, accounts, vendors, noteAction] = await Promise.all([
|
const [vessels, sites, accounts, vendors, noteAction] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
po.status === "EDITS_REQUESTED"
|
po.status === "EDITS_REQUESTED"
|
||||||
|
|
@ -42,6 +44,12 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
: Promise.resolve(null),
|
: Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const costCentres: CostCentreOption[] = [
|
||||||
|
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code} — ${v.name}`, group: "Vessels" as const })),
|
||||||
|
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code} — ${s.name}`, group: "Sites" as const })),
|
||||||
|
];
|
||||||
|
const initialCostCentreRef = po.vesselId ? `v:${po.vesselId}` : po.siteId ? `s:${po.siteId}` : "";
|
||||||
|
|
||||||
const serializedPo = {
|
const serializedPo = {
|
||||||
...po,
|
...po,
|
||||||
totalAmount: po.totalAmount.toNumber(),
|
totalAmount: po.totalAmount.toNumber(),
|
||||||
|
|
@ -60,7 +68,7 @@ export default async function EditPoPage({ params }: Props) {
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Edit Purchase Order</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Edit Purchase Order</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p>
|
<p className="mt-1 text-sm text-neutral-500 font-mono">{po.poNumber}</p>
|
||||||
</div>
|
</div>
|
||||||
<EditPoForm po={serializedPo} vessels={vessels} accounts={accounts} vendors={vendors} managerNoteAuthor={noteAction?.actor.name ?? null} />
|
<EditPoForm po={serializedPo} costCentres={costCentres} initialCostCentreRef={initialCostCentreRef} accounts={accounts} vendors={vendors} managerNoteAuthor={noteAction?.actor.name ?? null} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ export default async function PoDetailPage({ params }: Props) {
|
||||||
include: {
|
include: {
|
||||||
submitter: true,
|
submitter: true,
|
||||||
vessel: true,
|
vessel: true,
|
||||||
|
site: { select: { id: true, name: true } },
|
||||||
account: true,
|
account: true,
|
||||||
vendor: true,
|
vendor: true,
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import type { ParsedImportLine } from "@/app/api/po/import/route";
|
||||||
|
|
||||||
export type ImportPoInput = {
|
export type ImportPoInput = {
|
||||||
title: string;
|
title: string;
|
||||||
vesselId: string;
|
costCentreRef: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
vendorId?: string;
|
vendorId?: string;
|
||||||
piQuotationNo?: string;
|
piQuotationNo?: string;
|
||||||
|
|
@ -32,6 +32,9 @@ export async function importPo(
|
||||||
return { error: "You do not have permission to import purchase orders." };
|
return { error: "You do not have permission to import purchase orders." };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const importVesselId = input.costCentreRef.startsWith("v:") ? input.costCentreRef.slice(2) : null;
|
||||||
|
const importSiteId = input.costCentreRef.startsWith("s:") ? input.costCentreRef.slice(2) : null;
|
||||||
|
|
||||||
const total = input.lineItems.reduce(
|
const total = input.lineItems.reduce(
|
||||||
(sum, item) => sum + item.quantity * item.unitPrice * (1 + (item.gstRate ?? 0.18)),
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + (item.gstRate ?? 0.18)),
|
||||||
0
|
0
|
||||||
|
|
@ -44,7 +47,8 @@ export async function importPo(
|
||||||
status: "DRAFT",
|
status: "DRAFT",
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
currency: "INR",
|
currency: "INR",
|
||||||
vesselId: input.vesselId,
|
vesselId: importVesselId,
|
||||||
|
siteId: importSiteId,
|
||||||
accountId: input.accountId,
|
accountId: input.accountId,
|
||||||
vendorId: input.vendorId ?? null,
|
vendorId: input.vendorId ?? null,
|
||||||
piQuotationNo: input.piQuotationNo ?? null,
|
piQuotationNo: input.piQuotationNo ?? null,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
|
|
||||||
import { useState, useRef } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import type { Vessel, Account, Vendor } from "@prisma/client";
|
import type { Account, Vendor } from "@prisma/client";
|
||||||
|
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import { importPo } from "./actions";
|
import { importPo } from "./actions";
|
||||||
import type { ParsedImport } from "@/app/api/po/import/route";
|
import type { ParsedImport } from "@/app/api/po/import/route";
|
||||||
import { formatCurrency } from "@/lib/utils";
|
import { formatCurrency } from "@/lib/utils";
|
||||||
|
|
@ -11,7 +12,7 @@ const INPUT_CLS =
|
||||||
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: Vessel[];
|
costCentres: CostCentreOption[];
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
}
|
}
|
||||||
|
|
@ -19,12 +20,12 @@ interface Props {
|
||||||
type PreviewState = {
|
type PreviewState = {
|
||||||
parsed: ParsedImport;
|
parsed: ParsedImport;
|
||||||
title: string;
|
title: string;
|
||||||
vesselId: string;
|
costCentreRef: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
vendorId: string;
|
vendorId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ImportForm({ vessels, accounts, vendors }: Props) {
|
export function ImportForm({ costCentres, accounts, vendors }: Props) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
const [parsing, setParsing] = useState(false);
|
const [parsing, setParsing] = useState(false);
|
||||||
|
|
@ -64,7 +65,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
||||||
title: parsed.vendorName
|
title: parsed.vendorName
|
||||||
? `${parsed.vendorName} — Import`
|
? `${parsed.vendorName} — Import`
|
||||||
: "Imported Purchase Order",
|
: "Imported Purchase Order",
|
||||||
vesselId: vessels[0]?.id ?? "",
|
costCentreRef: costCentres[0]?.ref ?? "",
|
||||||
accountId: accounts[0]?.id ?? "",
|
accountId: accounts[0]?.id ?? "",
|
||||||
vendorId: matchedVendor?.id ?? "",
|
vendorId: matchedVendor?.id ?? "",
|
||||||
});
|
});
|
||||||
|
|
@ -83,7 +84,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
||||||
|
|
||||||
const result = await importPo({
|
const result = await importPo({
|
||||||
title: preview.title,
|
title: preview.title,
|
||||||
vesselId: preview.vesselId,
|
costCentreRef: preview.costCentreRef,
|
||||||
accountId: preview.accountId,
|
accountId: preview.accountId,
|
||||||
vendorId: preview.vendorId || undefined,
|
vendorId: preview.vendorId || undefined,
|
||||||
piQuotationNo: preview.parsed.piQuotationNo || undefined,
|
piQuotationNo: preview.parsed.piQuotationNo || undefined,
|
||||||
|
|
@ -182,18 +183,27 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
||||||
Cost Centre <span className="text-danger">*</span>
|
Cost Centre <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={preview.vesselId}
|
value={preview.costCentreRef}
|
||||||
onChange={(e) => setPreview({ ...preview, vesselId: e.target.value })}
|
onChange={(e) => setPreview({ ...preview, costCentreRef: e.target.value })}
|
||||||
required
|
required
|
||||||
className={INPUT_CLS}
|
className={INPUT_CLS}
|
||||||
>
|
>
|
||||||
<option value="">Select cost centre…</option>
|
<option value="">Select cost centre…</option>
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
<optgroup label="Vessels">
|
||||||
|
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Sites">
|
||||||
|
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Account <span className="text-danger">*</span>
|
Accounting Code <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={preview.accountId}
|
value={preview.accountId}
|
||||||
|
|
@ -285,7 +295,7 @@ export function ImportForm({ vessels, accounts, vendors }: Props) {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || !preview.vesselId || !preview.accountId}
|
disabled={submitting || !preview.costCentreRef || !preview.accountId}
|
||||||
className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
|
className="rounded-lg bg-primary-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60 transition-colors"
|
||||||
>
|
>
|
||||||
{submitting ? "Creating…" : "Create as Draft"}
|
{submitting ? "Creating…" : "Create as Draft"}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { auth } from "@/auth";
|
||||||
import { db } from "@/lib/db";
|
import { db } from "@/lib/db";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ImportForm } from "./import-form";
|
import { ImportForm } from "./import-form";
|
||||||
|
import type { CostCentreOption } from "@/app/(portal)/po/new/new-po-form";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Import Purchase Order" };
|
export const metadata: Metadata = { title: "Import Purchase Order" };
|
||||||
|
|
@ -13,22 +14,28 @@ export default async function ImportPoPage() {
|
||||||
const { role } = session.user;
|
const { role } = session.user;
|
||||||
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
|
if (!["MANAGER", "SUPERUSER", "ADMIN"].includes(role)) redirect("/dashboard");
|
||||||
|
|
||||||
const [vessels, accounts, vendors] = await Promise.all([
|
const [vessels, sites, accounts, vendors] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.vendor.findMany({ orderBy: { name: "asc" } }),
|
db.vendor.findMany({ orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const costCentres: CostCentreOption[] = [
|
||||||
|
...vessels.map((v) => ({ ref: `v:${v.id}`, label: `${v.code} — ${v.name}`, group: "Vessels" as const })),
|
||||||
|
...sites.map((s) => ({ ref: `s:${s.id}`, label: `${s.code} — ${s.name}`, group: "Sites" as const })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">Import Purchase Order</h1>
|
<h1 className="text-2xl font-semibold text-neutral-900">Import Purchase Order</h1>
|
||||||
<p className="mt-1 text-sm text-neutral-500">
|
<p className="mt-1 text-sm text-neutral-500">
|
||||||
Upload a Pelagia-format Excel PO file. Line items and vendor details are extracted automatically.
|
Upload a Pelagia-format Excel PO file. Line items and vendor details are extracted automatically.
|
||||||
You then select the vessel, account, and confirm before saving as a draft.
|
You then select the cost centre, accounting code, and confirm before saving as a draft.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ImportForm vessels={vessels} accounts={accounts} vendors={vendors} />
|
<ImportForm costCentres={costCentres} accounts={accounts} vendors={vendors} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ export async function createPo(
|
||||||
|
|
||||||
const parsed = createPoSchema.safeParse({
|
const parsed = createPoSchema.safeParse({
|
||||||
title: formData.get("title"),
|
title: formData.get("title"),
|
||||||
vesselId: formData.get("vesselId"),
|
costCentreRef: formData.get("costCentreRef"),
|
||||||
accountId: formData.get("accountId"),
|
accountId: formData.get("accountId"),
|
||||||
projectCode: formData.get("projectCode") || undefined,
|
projectCode: formData.get("projectCode") || undefined,
|
||||||
dateRequired: formData.get("dateRequired") || undefined,
|
dateRequired: formData.get("dateRequired") || undefined,
|
||||||
|
|
@ -75,6 +75,9 @@ export async function createPo(
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = parsed.data;
|
const data = parsed.data;
|
||||||
|
const vesselId = data.costCentreRef.startsWith("v:") ? data.costCentreRef.slice(2) : null;
|
||||||
|
const costCentreSiteId = data.costCentreRef.startsWith("s:") ? data.costCentreRef.slice(2) : null;
|
||||||
|
|
||||||
// totalAmount = grand total including GST
|
// totalAmount = grand total including GST
|
||||||
const total = data.lineItems.reduce(
|
const total = data.lineItems.reduce(
|
||||||
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
(sum, item) => sum + item.quantity * item.unitPrice * (1 + item.gstRate),
|
||||||
|
|
@ -88,7 +91,8 @@ export async function createPo(
|
||||||
status: intent === "submit" ? "SUBMITTED" : "DRAFT",
|
status: intent === "submit" ? "SUBMITTED" : "DRAFT",
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
currency: data.currency,
|
currency: data.currency,
|
||||||
vesselId: data.vesselId,
|
vesselId,
|
||||||
|
siteId: costCentreSiteId,
|
||||||
accountId: data.accountId,
|
accountId: data.accountId,
|
||||||
vendorId: data.vendorId ?? null,
|
vendorId: data.vendorId ?? null,
|
||||||
projectCode: data.projectCode ?? null,
|
projectCode: data.projectCode ?? null,
|
||||||
|
|
|
||||||
|
|
@ -3,27 +3,30 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { createPo } from "./actions";
|
import { createPo } from "./actions";
|
||||||
import type { Vessel, Account, Vendor } from "@prisma/client";
|
import type { Account, 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 { 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";
|
||||||
|
|
||||||
|
export type CostCentreOption = { ref: string; label: string; group: "Vessels" | "Sites" };
|
||||||
|
|
||||||
const INPUT_CLS =
|
const INPUT_CLS =
|
||||||
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
"w-full rounded-lg border border-neutral-300 px-3 py-2.5 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||||
|
|
||||||
const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 };
|
const EMPTY_LINE: LineItemInput = { name: "", description: "", quantity: 1, unit: "pc", size: "", unitPrice: 0, gstRate: 0.18 };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
vessels: Vessel[];
|
costCentres: CostCentreOption[];
|
||||||
accounts: Account[];
|
accounts: Account[];
|
||||||
vendors: Vendor[];
|
vendors: Vendor[];
|
||||||
initialLineItems?: LineItemInput[];
|
initialLineItems?: LineItemInput[];
|
||||||
initialVendorId?: string;
|
initialVendorId?: string;
|
||||||
|
initialCostCentreRef?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewPoForm({ vessels, accounts, vendors, initialLineItems, initialVendorId }: Props) {
|
export function NewPoForm({ costCentres, accounts, vendors, initialLineItems, initialVendorId, initialCostCentreRef }: 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]
|
||||||
|
|
@ -86,15 +89,24 @@ export function NewPoForm({ vessels, accounts, vendors, initialLineItems, initia
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
<label className="block text-sm font-medium text-neutral-700 mb-1.5">
|
||||||
Cost Centre <span className="text-danger">*</span>
|
Cost Centre <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select name="vesselId" required className={INPUT_CLS}>
|
<select name="costCentreRef" required defaultValue={initialCostCentreRef ?? ""} className={INPUT_CLS}>
|
||||||
<option value="">Select cost centre…</option>
|
<option value="">Select cost centre…</option>
|
||||||
{vessels.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
<optgroup label="Vessels">
|
||||||
|
{costCentres.filter((c) => c.group === "Vessels").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Sites">
|
||||||
|
{costCentres.filter((c) => c.group === "Sites").map((c) => (
|
||||||
|
<option key={c.ref} value={c.ref}>{c.label}</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
<label className="text-sm font-medium text-neutral-700">
|
<label className="text-sm font-medium text-neutral-700">
|
||||||
{multiAccount ? "Default Account" : "Account / Cost Centre"} <span className="text-danger">*</span>
|
{multiAccount ? "Default Accounting Code" : "Accounting Code"} <span className="text-danger">*</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
|
<label className="flex items-center gap-1.5 text-xs text-neutral-500 cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { db } from "@/lib/db";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
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 type { CostCentreOption } from "./new-po-form";
|
||||||
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";
|
||||||
|
|
@ -10,7 +11,7 @@ import type { CartItem } from "@/lib/cart";
|
||||||
export const metadata: Metadata = { title: "New Purchase Order" };
|
export const metadata: Metadata = { title: "New Purchase Order" };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
searchParams: Promise<{ cart?: string }>;
|
searchParams: Promise<{ cart?: string; costCentreRef?: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function NewPoPage({ searchParams }: Props) {
|
export default async function NewPoPage({ searchParams }: Props) {
|
||||||
|
|
@ -21,7 +22,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
redirect("/dashboard");
|
redirect("/dashboard");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { cart } = await searchParams;
|
const { cart, costCentreRef: initialCostCentreRef } = await searchParams;
|
||||||
|
|
||||||
let initialLineItems: LineItemInput[] | undefined;
|
let initialLineItems: LineItemInput[] | undefined;
|
||||||
let initialVendorId: string | undefined;
|
let initialVendorId: string | undefined;
|
||||||
|
|
@ -48,12 +49,18 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [vessels, accounts, vendors] = await Promise.all([
|
const [vessels, sites, accounts, vendors] = await Promise.all([
|
||||||
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vessel.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
|
db.site.findMany({ where: { isActive: true }, orderBy: { name: "asc" }, select: { id: true, name: true, code: true } }),
|
||||||
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.account.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
db.vendor.findMany({ where: { isActive: true }, orderBy: { name: "asc" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const costCentres: CostCentreOption[] = [
|
||||||
|
...vessels.map((v) => ({ ref: `v:${v.id}` as const, label: `${v.code} — ${v.name}`, group: "Vessels" as const })),
|
||||||
|
...sites.map((s) => ({ ref: `s:${s.id}` as const, label: `${s.code} — ${s.name}`, group: "Sites" as const })),
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl">
|
<div className="max-w-6xl">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
|
|
@ -62,7 +69,7 @@ export default async function NewPoPage({ searchParams }: Props) {
|
||||||
Fill in the details below. You can save as draft or submit directly for approval.
|
Fill in the details below. You can save as draft or submit directly for approval.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewPoForm vessels={vessels} accounts={accounts} vendors={vendors} initialLineItems={initialLineItems} initialVendorId={initialVendorId} />
|
<NewPoForm costCentres={costCentres} accounts={accounts} vendors={vendors} initialLineItems={initialLineItems} initialVendorId={initialVendorId} initialCostCentreRef={initialCostCentreRef} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
const po = await db.purchaseOrder.findUnique({
|
const po = await db.purchaseOrder.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
submitter: true, vessel: true, account: true,
|
submitter: true, vessel: true, site: { select: { name: true } }, account: true,
|
||||||
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
vendor: { include: { contacts: { where: { isPrimary: true }, take: 1 } } },
|
||||||
lineItems: { orderBy: { sortOrder: "asc" } },
|
lineItems: { orderBy: { sortOrder: "asc" } },
|
||||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||||
|
|
@ -253,7 +253,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
sc(7, 1, "Cost Centre", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
sc(7, 1, "Cost Centre", { font: fBold, fill: fillLbl, border: bordAll, align: alignL });
|
||||||
ws.mergeCells("A7:B7");
|
ws.mergeCells("A7:B7");
|
||||||
sc(7, 3, "Pelagia Marine Services Pvt. Ltd.", { font: fBase, border: bordAll, align: alignL });
|
sc(7, 3, "Pelagia Marine Services Pvt. Ltd.", { font: fBase, border: bordAll, align: alignL });
|
||||||
sc(7, 4, "Account", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
sc(7, 4, "Accounting Code", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
||||||
ws.mergeCells("D7:E7");
|
ws.mergeCells("D7:E7");
|
||||||
sc(7, 6, po.account.code, { font: fBase, border: bordAll, align: alignC });
|
sc(7, 6, po.account.code, { font: fBase, border: bordAll, align: alignC });
|
||||||
sc(7, 7, "Requested By", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
sc(7, 7, "Requested By", { font: fBold, fill: fillLbl, border: bordAll, align: alignC });
|
||||||
|
|
@ -593,7 +593,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
||||||
<tr>
|
<tr>
|
||||||
<td class="lbl" style="width:22%">Cost Centre</td>
|
<td class="lbl" style="width:22%">Cost Centre</td>
|
||||||
<td style="width:24%">Pelagia Marine Services Pvt. Ltd.</td>
|
<td style="width:24%">Pelagia Marine Services Pvt. Ltd.</td>
|
||||||
<td class="lbl" style="width:12%;text-align:center">Account</td>
|
<td class="lbl" style="width:12%;text-align:center">Accounting Code</td>
|
||||||
<td style="width:8%;text-align:center">${po.account.code}</td>
|
<td style="width:8%;text-align:center">${po.account.code}</td>
|
||||||
<td class="lbl" style="width:14%">Requested By</td>
|
<td class="lbl" style="width:14%">Requested By</td>
|
||||||
<td style="width:20%">${po.submitter.name}</td>
|
<td style="width:20%">${po.submitter.name}</td>
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ export async function GET(request: NextRequest) {
|
||||||
const format = sp.get("format") ?? "csv";
|
const format = sp.get("format") ?? "csv";
|
||||||
const dateFrom = sp.get("dateFrom");
|
const dateFrom = sp.get("dateFrom");
|
||||||
const dateTo = sp.get("dateTo");
|
const dateTo = sp.get("dateTo");
|
||||||
const vesselId = sp.get("vesselId");
|
const costCentreRef = sp.get("costCentreRef") ?? sp.get("vesselId");
|
||||||
const status = sp.get("status");
|
const status = sp.get("status");
|
||||||
|
|
||||||
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
const where: NonNullable<Parameters<typeof db.purchaseOrder.findMany>[0]>["where"] = {};
|
||||||
|
|
@ -38,12 +38,16 @@ export async function GET(request: NextRequest) {
|
||||||
}
|
}
|
||||||
where.createdAt = createdAt;
|
where.createdAt = createdAt;
|
||||||
}
|
}
|
||||||
if (vesselId) where.vesselId = vesselId;
|
if (costCentreRef) {
|
||||||
|
if (costCentreRef.startsWith("v:")) where.vesselId = costCentreRef.slice(2);
|
||||||
|
else if (costCentreRef.startsWith("s:")) where.siteId = costCentreRef.slice(2);
|
||||||
|
else where.vesselId = costCentreRef; // legacy plain vesselId
|
||||||
|
}
|
||||||
if (status) where.status = status as POStatus;
|
if (status) where.status = status as POStatus;
|
||||||
|
|
||||||
const orders = await db.purchaseOrder.findMany({
|
const orders = await db.purchaseOrder.findMany({
|
||||||
where,
|
where,
|
||||||
include: { submitter: true, vessel: true, account: true, vendor: true },
|
include: { submitter: true, vessel: true, site: { select: { name: true } }, account: true, vendor: true },
|
||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -53,7 +57,7 @@ export async function GET(request: NextRequest) {
|
||||||
<td>${po.poNumber}</td>
|
<td>${po.poNumber}</td>
|
||||||
<td>${po.title}</td>
|
<td>${po.title}</td>
|
||||||
<td>${PO_STATUS_LABELS[po.status] ?? po.status}</td>
|
<td>${PO_STATUS_LABELS[po.status] ?? po.status}</td>
|
||||||
<td>${po.vessel.name}</td>
|
<td>${po.vessel?.name ?? po.site?.name ?? "—"}</td>
|
||||||
<td>${po.submitter.name}</td>
|
<td>${po.submitter.name}</td>
|
||||||
<td>${po.vendor?.name ?? "—"}</td>
|
<td>${po.vendor?.name ?? "—"}</td>
|
||||||
<td style="text-align:right">${Number(po.totalAmount).toLocaleString("en-IN", { style: "currency", currency: "INR" })}</td>
|
<td style="text-align:right">${Number(po.totalAmount).toLocaleString("en-IN", { style: "currency", currency: "INR" })}</td>
|
||||||
|
|
@ -107,7 +111,7 @@ export async function GET(request: NextRequest) {
|
||||||
po.poNumber,
|
po.poNumber,
|
||||||
`"${po.title.replace(/"/g, '""')}"`,
|
`"${po.title.replace(/"/g, '""')}"`,
|
||||||
po.status,
|
po.status,
|
||||||
po.vessel.name,
|
po.vessel?.name ?? po.site?.name ?? "",
|
||||||
po.account.name,
|
po.account.name,
|
||||||
po.vendor?.name ?? "",
|
po.vendor?.name ?? "",
|
||||||
po.submitter.name,
|
po.submitter.name,
|
||||||
|
|
|
||||||
|
|
@ -50,14 +50,14 @@ const INVENTORY_ITEMS: NavItem[] = [
|
||||||
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
{ href: "/inventory/cart", label: "Cart", icon: ShoppingCart, roles: ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"] },
|
||||||
{ 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/vessels", label: "Cost Centres", icon: Ship, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/admin/vessels", label: "Vessels", icon: Ship, roles: ["MANAGER", "ADMIN"] },
|
||||||
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] },
|
{ href: "/admin/sites", label: "Sites", icon: MapPin, roles: ["MANAGER", "ADMIN"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const ADMIN_ITEMS: NavItem[] = [
|
const ADMIN_ITEMS: NavItem[] = [
|
||||||
{ href: "/admin/users", label: "Users", icon: Users },
|
{ href: "/admin/users", label: "Users", icon: Users },
|
||||||
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
|
{ href: "/admin/superuser-requests", label: "SuperUser Requests", icon: ShieldCheck },
|
||||||
{ href: "/admin/accounts", label: "Accounts", icon: Building2 },
|
{ href: "/admin/accounts", label: "Accounting Codes", icon: Building2 },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Sidebar({ userRole }: { userRole: Role }) {
|
export function Sidebar({ userRole }: { userRole: Role }) {
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,8 @@ type PoWithRelations = {
|
||||||
paidAt: Date | null;
|
paidAt: Date | null;
|
||||||
closedAt: Date | null;
|
closedAt: Date | null;
|
||||||
submitter: { id: string; name: string; email: string };
|
submitter: { id: string; name: string; email: string };
|
||||||
vessel: { id: string; name: string };
|
vessel: { id: string; name: string } | null;
|
||||||
|
site?: { id: string; name: string } | null;
|
||||||
account: { id: string; name: string; code: string };
|
account: { id: string; name: string; code: string };
|
||||||
vendor: {
|
vendor: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -228,7 +229,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
po.status === "MGR_REVIEW" &&
|
po.status === "MGR_REVIEW" &&
|
||||||
(currentRole === "MANAGER" || currentRole === "SUPERUSER") && (() => {
|
(currentRole === "MANAGER" || currentRole === "SUPERUSER") && (() => {
|
||||||
const snap = resubmitSnapshot.fields;
|
const snap = resubmitSnapshot.fields;
|
||||||
const currentVessel = po.vessel?.name ?? null;
|
const currentVessel = po.vessel?.name ?? po.site?.name ?? null;
|
||||||
const currentAccount = `${po.account.name} (${po.account.code})`;
|
const currentAccount = `${po.account.name} (${po.account.code})`;
|
||||||
const currentVendor = po.vendor?.name ?? null;
|
const currentVendor = po.vendor?.name ?? null;
|
||||||
const currentDateRequired = po.dateRequired?.toISOString() ?? null;
|
const currentDateRequired = po.dateRequired?.toISOString() ?? null;
|
||||||
|
|
@ -236,7 +237,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
const fieldChanges: { label: string; before: string | null; after: string | null }[] = [];
|
const fieldChanges: { label: string; before: string | null; after: string | null }[] = [];
|
||||||
if (snap.title !== po.title)
|
if (snap.title !== po.title)
|
||||||
fieldChanges.push({ label: "Title", before: snap.title, after: po.title });
|
fieldChanges.push({ label: "Title", before: snap.title, after: po.title });
|
||||||
if (snap.vesselId !== po.vessel.id)
|
if (snap.vesselId !== (po.vessel?.id ?? po.site?.id ?? ""))
|
||||||
fieldChanges.push({ label: "Cost Centre", before: snap.vessel, after: currentVessel });
|
fieldChanges.push({ label: "Cost Centre", before: snap.vessel, after: currentVessel });
|
||||||
if (snap.accountId !== po.account.id)
|
if (snap.accountId !== po.account.id)
|
||||||
fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount });
|
fieldChanges.push({ label: "Account", before: snap.account, after: currentAccount });
|
||||||
|
|
@ -286,8 +287,8 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
|
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
|
||||||
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Order Details</h3>
|
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Order Details</h3>
|
||||||
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
|
||||||
<div><dt className="text-neutral-500">Cost Centre</dt><dd className="font-medium text-neutral-900">{po.vessel?.name ?? "—"}</dd></div>
|
<div><dt className="text-neutral-500">Cost Centre</dt><dd className="font-medium text-neutral-900">{po.vessel?.name ?? po.site?.name ?? "—"}</dd></div>
|
||||||
<div><dt className="text-neutral-500">Account</dt><dd className="font-medium text-neutral-900">{po.account.name} ({po.account.code})</dd></div>
|
<div><dt className="text-neutral-500">Accounting Code</dt><dd className="font-medium text-neutral-900">{po.account.name} ({po.account.code})</dd></div>
|
||||||
<div><dt className="text-neutral-500">Requested By</dt><dd className="font-medium text-neutral-900">{po.submitter.name}</dd></div>
|
<div><dt className="text-neutral-500">Requested By</dt><dd className="font-medium text-neutral-900">{po.submitter.name}</dd></div>
|
||||||
{approvalAction && (
|
{approvalAction && (
|
||||||
<div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div>
|
<div><dt className="text-neutral-500">Approved By</dt><dd className="font-medium text-neutral-900">{approvalAction.actor.name}</dd></div>
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,11 @@ export const TC_DEFAULTS = {
|
||||||
|
|
||||||
export const createPoSchema = z.object({
|
export const createPoSchema = z.object({
|
||||||
title: z.string().min(1, "Title is required").max(200),
|
title: z.string().min(1, "Title is required").max(200),
|
||||||
vesselId: z.string().min(1, "Cost Centre is required"),
|
costCentreRef: z.string().min(1, "Cost Centre is required").refine(
|
||||||
accountId: z.string().min(1, "Account is required"),
|
(v) => v.startsWith("v:") || v.startsWith("s:"),
|
||||||
|
"Invalid cost centre selection"
|
||||||
|
),
|
||||||
|
accountId: z.string().min(1, "Accounting Code is required"),
|
||||||
projectCode: z.string().optional(),
|
projectCode: z.string().optional(),
|
||||||
dateRequired: z.string().optional(),
|
dateRequired: z.string().optional(),
|
||||||
vendorId: z.string().optional(),
|
vendorId: z.string().optional(),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable: make vesselId optional on PurchaseOrder (cost centre can now be a vessel or a site)
|
||||||
|
ALTER TABLE "PurchaseOrder" ALTER COLUMN "vesselId" DROP NOT NULL;
|
||||||
|
|
@ -253,8 +253,8 @@ model PurchaseOrder {
|
||||||
|
|
||||||
submitterId String
|
submitterId String
|
||||||
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
||||||
vesselId String
|
vesselId String?
|
||||||
vessel Vessel @relation(fields: [vesselId], references: [id])
|
vessel Vessel? @relation(fields: [vesselId], references: [id])
|
||||||
accountId String
|
accountId String
|
||||||
account Account @relation(fields: [accountId], references: [id])
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
vendorId String?
|
vendorId String?
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue