Compare commits
35 commits
feat/compa
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cac83013e | |||
| c32fb6979c | |||
| 4528c059aa | |||
| ff0539de92 | |||
| d0006a8fc7 | |||
| 6e25d701d2 | |||
| 2de883c70f | |||
| e4c4c370f6 | |||
| 65a9335de1 | |||
| cb661949d9 | |||
| 610c9aa56d | |||
| 6677ef4fcf | |||
| 4fee393c84 | |||
| 3b9bc0be1b | |||
| 0fdd899096 | |||
| 43d139234e | |||
|
|
cb25d2e5fd | ||
| 9de60200f9 | |||
| a8d772d63b | |||
| a197b966b1 | |||
| 058ba1d12e | |||
| 0b10ba5e54 | |||
| fbdc7b2235 | |||
| 9e787fd15f | |||
| 8ee077e548 | |||
| 991b7ca5dd | |||
| 4c53aeecb0 | |||
| b70eec261b | |||
| d9394e6afb | |||
| 4712fafb4b | |||
| e388ec917e | |||
| 9d08ca1990 | |||
| 6137d11e5f | |||
|
|
defd6e7a18 | ||
| 74d20cd452 |
79 changed files with 3700 additions and 78 deletions
|
|
@ -1,14 +1,22 @@
|
|||
name: PR checks
|
||||
|
||||
# Enforces the contribution policy on every PR into master (all gates hard):
|
||||
# Enforces the contribution policy on every PR into master — plus the crewing
|
||||
# stack branches (feat/crewing-*), which collect the stacked, feature-flagged
|
||||
# crewing phases (foundations → requisitions → candidates → …) before they merge
|
||||
# to master. Same hard gates:
|
||||
# - code changes must ship with tests (docs/config/automation are exempt)
|
||||
# - type-check is clean across the whole project (tests included)
|
||||
# - unit tests pass
|
||||
# - integration tests pass against an ephemeral Postgres (migrate + seed)
|
||||
# Runs on the pms1 host runner. See automation/README.md > "Contribution policy".
|
||||
#
|
||||
# Note: the workflow is evaluated from the branch under test, so the trigger list
|
||||
# must match it. The feat/crewing-* glob covers every branch in the stack so each
|
||||
# stacked phase PR is checked without further edits to this file.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [master, "feat/crewing-*"]
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
|
|
@ -56,3 +64,45 @@ jobs:
|
|||
set -e
|
||||
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||
cd App && pnpm test # jsdom unit tests, no DB — must pass
|
||||
|
||||
integration:
|
||||
runs-on: host
|
||||
steps:
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Integration tests (ephemeral Postgres)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export NVM_DIR="$HOME/.nvm"; . "$NVM_DIR/nvm.sh"
|
||||
|
||||
# Throwaway Postgres per run — isolated from prod / pelagia_test / staging.
|
||||
# A random host port avoids collisions with the host DB and concurrent runs.
|
||||
PG="ci-pg-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
|
||||
cleanup() { docker rm -f "$PG" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
docker rm -f "$PG" >/dev/null 2>&1 || true
|
||||
docker run -d --name "$PG" \
|
||||
-e POSTGRES_USER=ci -e POSTGRES_PASSWORD=ci -e POSTGRES_DB=pelagia_ci \
|
||||
-p 127.0.0.1::5432 postgres:16 >/dev/null
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
docker exec "$PG" pg_isready -U ci -d pelagia_ci >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
|
||||
PORT=$(docker inspect --format '{{ (index (index .NetworkSettings.Ports "5432/tcp") 0).HostPort }}' "$PG")
|
||||
export DATABASE_URL="postgresql://ci:ci@127.0.0.1:${PORT}/pelagia_ci"
|
||||
# Non-secret placeholders so auth.ts (reads these at module load) boots in dev mode.
|
||||
export NEXTAUTH_SECRET="ci-secret"
|
||||
export NEXTAUTH_URL="http://localhost:3000"
|
||||
export AZURE_AD_CLIENT_ID="placeholder"
|
||||
export AZURE_AD_CLIENT_SECRET="placeholder"
|
||||
export AZURE_AD_TENANT_ID="placeholder"
|
||||
|
||||
cd App
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm db:generate
|
||||
pnpm db:migrate:deploy # apply migrations to the fresh DB
|
||||
pnpm db:seed # dev seed — integration tests rely on it
|
||||
pnpm test:integration # node + real DB — must pass
|
||||
|
|
|
|||
|
|
@ -118,6 +118,16 @@ Inventory (`ItemInventory`, keyed by `productId` + `siteId`) is **incremented at
|
|||
|
||||
`/po/import` parses a Pelagia-format Excel PO and saves it **directly as `CLOSED`** (historical record, bypasses approval). It auto-detects the company (by header/code), auto-matches the vessel by code, **auto-creates the vendor and any unknown products**, and upserts per-vendor prices.
|
||||
|
||||
### Crewing (feature-flagged)
|
||||
|
||||
A crew-management module built incrementally per the **wiki `Crewing-Implementation-Spec`** (the authoritative spec), behind `NEXT_PUBLIC_CREWING_ENABLED` (off unless `"true"`). It is delivered in phases (spec §12); only the **Foundations** layer ships so far:
|
||||
|
||||
- **Role:** `SITE_STAFF` (the new `Role` enum member) — PM / Assistant PM / Site In-charge log in as site staff and act on behalf of crew. MPO is `MANNING`.
|
||||
- **Permissions:** `lib/permissions.ts` holds the full crewing grant matrix (spec §6) as the source of truth — `PO_ROLE_PERMISSIONS` + `CREWING_ROLE_PERMISSIONS` are merged into `ROLE_PERMISSIONS`. Notable rules: MPO has **no** attendance/leave; `decide_leave`/`approve_*`/`select_candidate` are Manager-only; `manage_ranks` is Manager + Admin.
|
||||
- **Reference data:** `Rank` is a self-referential org-chart hierarchy (like `Account`), seeded from `prisma/rank-data.ts`; `RankDocRequirement` (seeded from `prisma/rank-doc-data.ts`) lists the documents each rank must hold. Both seed via the shared `prisma/seed-ranks.ts` in dev **and** prod seeds. `Rank.grantsLogin` is true only for the three management ranks.
|
||||
- **Admin screen:** `/admin/ranks` ("Ranks & documents", gated by `manage_ranks` + the flag) — the rank hierarchy card + per-rank required-documents card.
|
||||
- The sidebar has a flag-gated **Crewing** section scaffold (`CREWING_ITEMS`, empty until later phases) and the Ranks link under Administration.
|
||||
|
||||
### 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`.
|
||||
|
|
@ -142,6 +152,7 @@ FORGEJO_URL, FORGEJO_REPO, FORGEJO_TOKEN
|
|||
|
||||
GST_SERVICE_URL # GstService microservice (defaults to localhost:3003)
|
||||
NEXT_PUBLIC_INVENTORY_ENABLED # Inventory feature flag
|
||||
NEXT_PUBLIC_CREWING_ENABLED # Crewing module feature flag (opt-in "true"; off by default)
|
||||
NEXT_PUBLIC_ENV_LABEL # When set, shows a non-prod banner (EnvBanner). Leave unset in prod.
|
||||
```
|
||||
|
||||
|
|
|
|||
187
App/app/(portal)/admin/ranks/actions.ts
Normal file
187
App/app/(portal)/admin/ranks/actions.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
"use server";
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { RankCategory, SeafarerDocType } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
type ActionResult = { ok: true } | { error: string };
|
||||
|
||||
async function guard(): Promise<{ error: string } | null> {
|
||||
if (!CREWING_ENABLED) return { error: "Crewing is not enabled" };
|
||||
const session = await auth();
|
||||
if (!session?.user || !hasPermission(session.user.role, "manage_ranks")) {
|
||||
return { error: "Unauthorized" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const rankSchema = z.object({
|
||||
code: z.string().trim().min(1, "Code is required").max(16, "Code is too long"),
|
||||
name: z.string().trim().min(1, "Name is required"),
|
||||
description: z.string().optional(),
|
||||
parentId: z.string().optional(),
|
||||
category: z.nativeEnum(RankCategory),
|
||||
isSeafarer: z.boolean(),
|
||||
grantsLogin: z.boolean(),
|
||||
});
|
||||
|
||||
function parseRank(formData: FormData) {
|
||||
return rankSchema.safeParse({
|
||||
code: formData.get("code"),
|
||||
name: formData.get("name"),
|
||||
description: (formData.get("description") as string) || undefined,
|
||||
parentId: (formData.get("parentId") as string) || undefined,
|
||||
category: formData.get("category"),
|
||||
isSeafarer: formData.get("isSeafarer") === "on" || formData.get("isSeafarer") === "true",
|
||||
grantsLogin: formData.get("grantsLogin") === "on" || formData.get("grantsLogin") === "true",
|
||||
});
|
||||
}
|
||||
|
||||
// True if `candidateParentId` is `rankId` itself or one of its descendants —
|
||||
// setting it as the parent would create a cycle.
|
||||
async function wouldCycle(rankId: string, candidateParentId: string): Promise<boolean> {
|
||||
if (rankId === candidateParentId) return true;
|
||||
const all = await db.rank.findMany({ select: { id: true, parentId: true } });
|
||||
const childrenOf = new Map<string, string[]>();
|
||||
for (const r of all) {
|
||||
if (r.parentId) {
|
||||
const list = childrenOf.get(r.parentId) ?? [];
|
||||
list.push(r.id);
|
||||
childrenOf.set(r.parentId, list);
|
||||
}
|
||||
}
|
||||
const stack = [rankId];
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!;
|
||||
if (cur === candidateParentId) return true;
|
||||
stack.push(...(childrenOf.get(cur) ?? []));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function createRank(formData: FormData): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
|
||||
const parsed = parseRank(formData);
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const data = parsed.data;
|
||||
|
||||
const exists = await db.rank.findUnique({ where: { code: data.code } });
|
||||
if (exists) return { error: "A rank with that code already exists" };
|
||||
|
||||
await db.rank.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
parentId: data.parentId ?? null,
|
||||
category: data.category,
|
||||
isSeafarer: data.isSeafarer,
|
||||
grantsLogin: data.grantsLogin,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/ranks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updateRank(formData: FormData): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
|
||||
const id = formData.get("id") as string;
|
||||
if (!id) return { error: "Rank ID is required" };
|
||||
|
||||
const parsed = parseRank(formData);
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const data = parsed.data;
|
||||
|
||||
const conflict = await db.rank.findFirst({ where: { code: data.code, id: { not: id } } });
|
||||
if (conflict) return { error: "Another rank already uses that code" };
|
||||
|
||||
if (data.parentId && (await wouldCycle(id, data.parentId))) {
|
||||
return { error: "A rank cannot report to itself or one of its sub-ranks" };
|
||||
}
|
||||
|
||||
await db.rank.update({
|
||||
where: { id },
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
description: data.description ?? null,
|
||||
parentId: data.parentId ?? null,
|
||||
category: data.category,
|
||||
isSeafarer: data.isSeafarer,
|
||||
grantsLogin: data.grantsLogin,
|
||||
},
|
||||
});
|
||||
revalidatePath("/admin/ranks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function deleteRank(id: string): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
|
||||
const hasChildren = await db.rank.findFirst({ where: { parentId: id } });
|
||||
if (hasChildren) return { error: "Cannot delete: this rank has sub-ranks. Reassign or remove them first." };
|
||||
|
||||
// Document requirements cascade on delete.
|
||||
await db.rank.delete({ where: { id } });
|
||||
revalidatePath("/admin/ranks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function toggleRankActive(id: string): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
|
||||
const rank = await db.rank.findUnique({ where: { id }, select: { isActive: true } });
|
||||
if (!rank) return { error: "Rank not found" };
|
||||
|
||||
await db.rank.update({ where: { id }, data: { isActive: !rank.isActive } });
|
||||
revalidatePath("/admin/ranks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const docReqSchema = z.object({
|
||||
rankId: z.string().min(1),
|
||||
docType: z.nativeEnum(SeafarerDocType),
|
||||
isMandatory: z.boolean(),
|
||||
note: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function addRankDocRequirement(formData: FormData): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
|
||||
const parsed = docReqSchema.safeParse({
|
||||
rankId: formData.get("rankId"),
|
||||
docType: formData.get("docType"),
|
||||
isMandatory: formData.get("isMandatory") === "on" || formData.get("isMandatory") === "true",
|
||||
note: (formData.get("note") as string) || undefined,
|
||||
});
|
||||
if (!parsed.success) return { error: parsed.error.errors[0]?.message ?? "Validation failed" };
|
||||
const data = parsed.data;
|
||||
|
||||
await db.rankDocRequirement.upsert({
|
||||
where: { rankId_docType: { rankId: data.rankId, docType: data.docType } },
|
||||
update: { isMandatory: data.isMandatory, note: data.note ?? null },
|
||||
create: { rankId: data.rankId, docType: data.docType, isMandatory: data.isMandatory, note: data.note ?? null },
|
||||
});
|
||||
revalidatePath("/admin/ranks");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function removeRankDocRequirement(id: string): Promise<ActionResult> {
|
||||
const denied = await guard();
|
||||
if (denied) return denied;
|
||||
|
||||
await db.rankDocRequirement.delete({ where: { id } });
|
||||
revalidatePath("/admin/ranks");
|
||||
return { ok: true };
|
||||
}
|
||||
44
App/app/(portal)/admin/ranks/page.tsx
Normal file
44
App/app/(portal)/admin/ranks/page.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { redirect, notFound } from "next/navigation";
|
||||
import { RanksManager } from "./ranks-manager";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = { title: "Ranks & Documents" };
|
||||
|
||||
export default async function AdminRanksPage() {
|
||||
// Dark unless the crewing module is switched on.
|
||||
if (!CREWING_ENABLED) notFound();
|
||||
|
||||
const session = await auth();
|
||||
if (!session?.user) redirect("/login");
|
||||
if (!hasPermission(session.user.role, "manage_ranks")) redirect("/dashboard");
|
||||
|
||||
const ranks = await db.rank.findMany({
|
||||
orderBy: [{ name: "asc" }],
|
||||
include: { docRequirements: { orderBy: { docType: "asc" } } },
|
||||
});
|
||||
|
||||
// Flatten to plain props (no Date/Decimal crosses the server→client boundary).
|
||||
const rows = ranks.map((r) => ({
|
||||
id: r.id,
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
description: r.description,
|
||||
category: r.category,
|
||||
isSeafarer: r.isSeafarer,
|
||||
grantsLogin: r.grantsLogin,
|
||||
isActive: r.isActive,
|
||||
parentId: r.parentId,
|
||||
docRequirements: r.docRequirements.map((d) => ({
|
||||
id: d.id,
|
||||
docType: d.docType,
|
||||
isMandatory: d.isMandatory,
|
||||
note: d.note,
|
||||
})),
|
||||
}));
|
||||
|
||||
return <RanksManager ranks={rows} />;
|
||||
}
|
||||
132
App/app/(portal)/admin/ranks/rank-doc-panel.tsx
Normal file
132
App/app/(portal)/admin/ranks/rank-doc-panel.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { SeafarerDocType } from "@prisma/client";
|
||||
import type { RankRow } from "./ranks-manager";
|
||||
import { addRankDocRequirement, removeRankDocRequirement } from "./actions";
|
||||
|
||||
// Listed (not imported as a runtime enum) to keep @prisma/client out of the client bundle.
|
||||
const DOC_TYPES: { value: SeafarerDocType; label: string }[] = [
|
||||
{ value: "STCW", label: "STCW" },
|
||||
{ value: "AADHAAR", label: "Aadhaar" },
|
||||
{ value: "PAN", label: "PAN" },
|
||||
{ value: "PASSPORT", label: "Passport" },
|
||||
{ value: "CDC", label: "CDC" },
|
||||
{ value: "COC", label: "COC" },
|
||||
{ value: "PHOTOGRAPH", label: "Photograph" },
|
||||
{ value: "DRIVING_LICENSE", label: "Driving licence" },
|
||||
{ value: "MEDICAL_FITNESS", label: "Medical fitness" },
|
||||
{ value: "CONTRACT_LETTER", label: "Contract letter" },
|
||||
];
|
||||
|
||||
const DOC_LABEL = Object.fromEntries(DOC_TYPES.map((d) => [d.value, d.label])) as Record<SeafarerDocType, string>;
|
||||
|
||||
const INPUT =
|
||||
"w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20";
|
||||
|
||||
export function RankDocPanel({ rank }: { rank: RankRow | null }) {
|
||||
const router = useRouter();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
if (!rank) {
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white p-8 text-center text-sm text-neutral-400">
|
||||
Select a rank to manage its required documents.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function handleAdd(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setPending(true);
|
||||
setError("");
|
||||
const fd = new FormData(e.currentTarget);
|
||||
fd.set("rankId", rank!.id);
|
||||
const result = await addRankDocRequirement(fd);
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setPending(false);
|
||||
} else {
|
||||
setPending(false);
|
||||
setAdding(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
await removeRankDocRequirement(id);
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-neutral-900">Required documents</h2>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">{rank.code} — {rank.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setAdding((v) => !v)}
|
||||
className="rounded-lg border border-primary-200 bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700 hover:bg-primary-100"
|
||||
>
|
||||
{adding ? "Close" : "+ Add"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{adding && (
|
||||
<form onSubmit={handleAdd} className="px-4 py-3 border-b border-neutral-100 bg-neutral-50/50 space-y-2">
|
||||
<select name="docType" className={INPUT} defaultValue={DOC_TYPES[0].value}>
|
||||
{DOC_TYPES.map((d) => (
|
||||
<option key={d.value} value={d.value}>{d.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input type="checkbox" name="isMandatory" defaultChecked className="h-4 w-4" />
|
||||
Mandatory (uncheck for conditional)
|
||||
</label>
|
||||
<input name="note" className={INPUT} placeholder="Note (optional)" />
|
||||
{error && <p className="text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className="rounded-lg bg-primary-600 px-3 py-1.5 text-sm font-semibold text-white hover:bg-primary-700 disabled:opacity-60"
|
||||
>
|
||||
{pending ? "Saving…" : "Add requirement"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{rank.docRequirements.length === 0 ? (
|
||||
<p className="px-4 py-8 text-center text-sm text-neutral-400">No required documents for this rank.</p>
|
||||
) : (
|
||||
<div>
|
||||
{rank.docRequirements.map((d) => (
|
||||
<div key={d.id} className="flex items-center gap-2 px-4 py-2.5 border-b border-neutral-100 last:border-0">
|
||||
<span className="text-sm text-neutral-900 flex-1">{DOC_LABEL[d.docType] ?? d.docType}</span>
|
||||
{d.note && <span className="text-xs text-neutral-400 max-w-[10rem] truncate">{d.note}</span>}
|
||||
<span
|
||||
className={
|
||||
d.isMandatory
|
||||
? "rounded-full bg-warning-100 text-warning-700 px-2 py-0.5 text-xs font-medium"
|
||||
: "rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs font-medium"
|
||||
}
|
||||
>
|
||||
{d.isMandatory ? "Mandatory" : "Conditional"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleRemove(d.id)}
|
||||
className="text-xs text-danger-700 hover:underline"
|
||||
title="Remove"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
App/app/(portal)/admin/ranks/rank-form.tsx
Normal file
184
App/app/(portal)/admin/ranks/rank-form.tsx
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { AdminDialog } from "@/components/ui/admin-dialog";
|
||||
import { createRank, updateRank } from "./actions";
|
||||
import type { RankRow } from "./ranks-manager";
|
||||
|
||||
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 RankFormFields({ rank, allRanks }: { rank?: RankRow; allRanks: RankRow[] }) {
|
||||
const parentOptions = allRanks.filter((r) => !rank || r.id !== rank.id);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Code *</label>
|
||||
<input name="code" defaultValue={rank?.code} required maxLength={16} placeholder="e.g. SDO" className={INPUT} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Name *</label>
|
||||
<input name="name" defaultValue={rank?.name} required placeholder="e.g. Sr. Dredge Operator" className={INPUT} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Reports to</label>
|
||||
<select name="parentId" defaultValue={rank?.parentId ?? ""} className={INPUT}>
|
||||
<option value="">— Top of the org —</option>
|
||||
{parentOptions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.code} — {r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Category</label>
|
||||
<select name="category" defaultValue={rank?.category ?? "OPERATIONAL"} className={INPUT}>
|
||||
<option value="OPERATIONAL">Operational</option>
|
||||
<option value="SUPPORT">Support</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 pt-1">
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input type="checkbox" name="isSeafarer" defaultChecked={rank?.isSeafarer ?? false} className="h-4 w-4" />
|
||||
Seafarer (holds STCW / CDC etc.)
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input type="checkbox" name="grantsLogin" defaultChecked={rank?.grantsLogin ?? false} className="h-4 w-4" />
|
||||
Grants portal login
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-700 mb-1">Description</label>
|
||||
<input name="description" defaultValue={rank?.description ?? ""} className={INPUT} placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddRankButton({ allRanks }: { allRanks: RankRow[] }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setPending(true);
|
||||
setError("");
|
||||
const result = await createRank(new FormData(e.currentTarget));
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setPending(false);
|
||||
} else {
|
||||
setPending(false);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-lg bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
+ Add Rank
|
||||
</button>
|
||||
<AdminDialog title="Add Rank" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<RankFormFields allRanks={allRanks} />
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{pending ? "Creating…" : "Create Rank"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditRankButton({
|
||||
rank,
|
||||
allRanks,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
}: {
|
||||
rank: RankRow;
|
||||
allRanks: RankRow[];
|
||||
open?: boolean;
|
||||
onOpenChange?: (v: boolean) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const open = isControlled ? controlledOpen : internalOpen;
|
||||
const setOpen = isControlled ? (onOpenChange ?? (() => {})) : setInternalOpen;
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setPending(true);
|
||||
setError("");
|
||||
const fd = new FormData(e.currentTarget);
|
||||
fd.set("id", rank.id);
|
||||
const result = await updateRank(fd);
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setPending(false);
|
||||
} else {
|
||||
setPending(false);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminDialog title="Edit Rank" open={open} onClose={() => setOpen(false)}>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<RankFormFields rank={rank} allRanks={allRanks} />
|
||||
{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">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<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"
|
||||
>
|
||||
{pending ? "Saving…" : "Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminDialog>
|
||||
);
|
||||
}
|
||||
200
App/app/(portal)/admin/ranks/ranks-manager.tsx
Normal file
200
App/app/(portal)/admin/ranks/ranks-manager.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { RankCategory, SeafarerDocType } from "@prisma/client";
|
||||
import { AddRankButton, EditRankButton } from "./rank-form";
|
||||
import { RankDocPanel } from "./rank-doc-panel";
|
||||
import { RowActionsMenu, RowActionsItem, RowActionsDestructiveItem, RowActionsSeparator } from "@/components/ui/row-actions-menu";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog";
|
||||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { deleteRank, toggleRankActive } from "./actions";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type DocReqRow = {
|
||||
id: string;
|
||||
docType: SeafarerDocType;
|
||||
isMandatory: boolean;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
export type RankRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
category: RankCategory;
|
||||
isSeafarer: boolean;
|
||||
grantsLogin: boolean;
|
||||
isActive: boolean;
|
||||
parentId: string | null;
|
||||
docRequirements: DocReqRow[];
|
||||
};
|
||||
|
||||
type TreeNode = RankRow & { children: TreeNode[] };
|
||||
|
||||
function buildTree(ranks: RankRow[]): TreeNode[] {
|
||||
const byId = new Map<string, TreeNode>();
|
||||
ranks.forEach((r) => byId.set(r.id, { ...r, children: [] }));
|
||||
const roots: TreeNode[] = [];
|
||||
byId.forEach((node) => {
|
||||
if (node.parentId && byId.has(node.parentId)) {
|
||||
byId.get(node.parentId)!.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
const sortRec = (nodes: TreeNode[]) => {
|
||||
nodes.sort((a, b) => a.name.localeCompare(b.name));
|
||||
nodes.forEach((n) => sortRec(n.children));
|
||||
};
|
||||
sortRec(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function RankActionsMenu({ rank, allRanks }: { rank: RankRow; allRanks: RankRow[] }) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [toggleOpen, setToggleOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<RowActionsMenu>
|
||||
<RowActionsItem onClick={() => setEditOpen(true)}>Edit</RowActionsItem>
|
||||
<RowActionsItem onClick={() => setToggleOpen(true)}>
|
||||
{rank.isActive ? "Deactivate" : "Activate"}
|
||||
</RowActionsItem>
|
||||
<RowActionsSeparator />
|
||||
<RowActionsDestructiveItem onClick={() => setDeleteOpen(true)}>Delete</RowActionsDestructiveItem>
|
||||
</RowActionsMenu>
|
||||
<EditRankButton rank={rank} allRanks={allRanks} open={editOpen} onOpenChange={setEditOpen} />
|
||||
<DeleteConfirmDialog
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
label={`${rank.code} — ${rank.name}`}
|
||||
onConfirm={() => deleteRank(rank.id)}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={toggleOpen}
|
||||
onOpenChange={setToggleOpen}
|
||||
title={rank.isActive ? `Deactivate ${rank.name}?` : `Activate ${rank.name}?`}
|
||||
description={
|
||||
rank.isActive
|
||||
? `${rank.name} will be hidden from new requisitions and crew records.`
|
||||
: `${rank.name} will become available again.`
|
||||
}
|
||||
confirmLabel={rank.isActive ? "Deactivate" : "Activate"}
|
||||
onConfirm={() => toggleRankActive(rank.id)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RankRowView({
|
||||
node,
|
||||
depth,
|
||||
allRanks,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
allRanks: RankRow[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
const isSelected = node.id === selectedId;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 border-b border-neutral-100 last:border-0 cursor-pointer",
|
||||
isSelected ? "bg-primary-50" : "hover:bg-neutral-50"
|
||||
)}
|
||||
style={{ paddingLeft: 12 + depth * 20 }}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
<span className="font-mono text-xs text-neutral-400 w-12 shrink-0">{node.code}</span>
|
||||
<span className={cn("text-sm flex-1", node.isActive ? "text-neutral-900" : "text-neutral-400 line-through")}>
|
||||
{node.name}
|
||||
</span>
|
||||
{node.grantsLogin && (
|
||||
<span className="rounded-full bg-primary-100 text-primary-700 px-2 py-0.5 text-xs font-medium">Login</span>
|
||||
)}
|
||||
{node.isSeafarer && (
|
||||
<span className="rounded-full bg-neutral-100 text-neutral-600 px-2 py-0.5 text-xs font-medium">Seafarer</span>
|
||||
)}
|
||||
<span className="rounded-full bg-neutral-100 text-neutral-500 px-2 py-0.5 text-xs">{node.category}</span>
|
||||
<span className="text-xs text-neutral-400 w-16 text-right shrink-0">
|
||||
{node.docRequirements.length} doc{node.docRequirements.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<RankActionsMenu rank={node} allRanks={allRanks} />
|
||||
</div>
|
||||
</div>
|
||||
{node.children.map((child) => (
|
||||
<RankRowView
|
||||
key={child.id}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
allRanks={allRanks}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function RanksManager({ ranks }: { ranks: RankRow[] }) {
|
||||
const tree = buildTree(ranks);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(ranks[0]?.id ?? null);
|
||||
const selected = ranks.find((r) => r.id === selectedId) ?? null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-neutral-900">Ranks & Documents</h1>
|
||||
<p className="text-sm text-neutral-500 mt-0.5">
|
||||
{ranks.length} ranks · the crew org chart and the documents each rank must hold
|
||||
</p>
|
||||
</div>
|
||||
<AddRankButton allRanks={ranks} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* Rank hierarchy card */}
|
||||
<div className="lg:col-span-3 rounded-lg border border-neutral-200 bg-white overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-neutral-200 bg-neutral-50">
|
||||
<h2 className="text-sm font-semibold text-neutral-900">Rank hierarchy</h2>
|
||||
<p className="text-xs text-neutral-500 mt-0.5">
|
||||
The org chart. <span className="text-primary-700 font-medium">Login</span> ranks (PM, Assistant PM, Site
|
||||
In-charge) map to a portal account; all others are crew records.
|
||||
</p>
|
||||
</div>
|
||||
{tree.length === 0 ? (
|
||||
<p className="px-4 py-12 text-center text-neutral-400">No ranks yet. Add a top-level rank to begin.</p>
|
||||
) : (
|
||||
<div>
|
||||
{tree.map((node) => (
|
||||
<RankRowView
|
||||
key={node.id}
|
||||
node={node}
|
||||
depth={0}
|
||||
allRanks={ranks}
|
||||
selectedId={selectedId}
|
||||
onSelect={setSelectedId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Required documents card */}
|
||||
<div className="lg:col-span-2">
|
||||
<RankDocPanel rank={selected} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ export default async function SiteDetailPage({ params }: Props) {
|
|||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft", MGR_REVIEW: "Under Review", MGR_APPROVED: "Approved",
|
||||
SENT_FOR_PAYMENT: "Sent for Payment", PAID_DELIVERED: "Paid", CLOSED: "Closed",
|
||||
SUBMITTED: "Submitted", REJECTED: "Rejected",
|
||||
SUBMITTED: "Submitted", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
export default async function SuperUserRequestsPage() {
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
const CHIPS = ["Manning", "Technical", "Accounts", "Manager", "Superuser", "Auditor", "Admin", "Active", "Inactive"];
|
||||
|
|
|
|||
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
2
App/app/(portal)/admin/vendors/[id]/page.tsx
vendored
|
|
@ -19,7 +19,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||
EDITS_REQUESTED: "Edits Requested", VENDOR_ID_PENDING: "Vendor ID Pending",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export default async function VesselDetailPage({ params }: Props) {
|
|||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Under Review",
|
||||
MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected",
|
||||
PAID_DELIVERED: "Paid", CLOSED: "Closed", REJECTED: "Rejected", CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
const totalSpend = vessel.purchaseOrders.filter(p => p.status === "CLOSED" || p.status === "PAID_DELIVERED")
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { db } from "@/lib/db";
|
|||
import { StatCard } from "@/components/dashboard/stat-card";
|
||||
import { SpendCharts } from "@/components/dashboard/spend-charts";
|
||||
import { PoStatusBadge } from "@/components/po/po-status-badge";
|
||||
import { formatCurrency, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign } from "lucide-react";
|
||||
import { formatCurrency, formatCompactINR, formatDate, POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { FileText, Clock, CheckCircle, DollarSign, IndianRupee } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
|
|
@ -182,7 +182,7 @@ async function ManagerDashboard() {
|
|||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<StatCard label="Awaiting Approval" value={awaitingCount} icon={Clock} color="orange" href="/approvals" />
|
||||
<StatCard label="Approved This Month" value={approvedThisMonth} icon={CheckCircle} color="green" href={`/history?approvedFrom=${startOfMonthParam}`} />
|
||||
<StatCard label="Total Approved Spend" value={formatCurrency(totalSpend)} icon={DollarSign} color="blue" />
|
||||
<StatCard label="Total Approved Spend" value={formatCompactINR(totalSpend)} icon={IndianRupee} color="blue" />
|
||||
</div>
|
||||
|
||||
{/* Recent approved POs */}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const STATUSES = [
|
|||
{ value: "PAID_DELIVERED", label: "Paid / Delivered" },
|
||||
{ value: "CLOSED", label: "Closed" },
|
||||
{ value: "REJECTED", label: "Rejected" },
|
||||
{ value: "CANCELLED", label: "Cancelled" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -115,7 +115,10 @@ export default async function HistoryPage({ searchParams }: Props) {
|
|||
</thead>
|
||||
<tbody className="divide-y divide-neutral-100">
|
||||
{orders.map((po) => (
|
||||
<tr key={po.id} className="hover:bg-neutral-50">
|
||||
<tr
|
||||
key={po.id}
|
||||
className={`hover:bg-neutral-50 ${po.status === "CANCELLED" ? "bg-neutral-50/60 text-neutral-400 [&_td]:text-neutral-400" : ""}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<Link href={`/po/${po.id}`} className="font-mono text-xs text-primary-600 hover:text-primary-700">
|
||||
{po.poNumber}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export function VendorsTable({
|
|||
? vendors.filter(
|
||||
(v) =>
|
||||
v.name.toLowerCase().includes(q) ||
|
||||
(v.vendorId && v.vendorId.toLowerCase().includes(q)) ||
|
||||
(v.gstin && v.gstin.toLowerCase().includes(q)) ||
|
||||
(v.address && v.address.toLowerCase().includes(q))
|
||||
)
|
||||
|
|
@ -89,7 +90,7 @@ export function VendorsTable({
|
|||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search by name, GSTIN or address…"
|
||||
placeholder="Search by name, ID, GSTIN or address…"
|
||||
className="w-full rounded-lg border border-neutral-200 py-2 pl-8 pr-8 text-sm focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
{query && (
|
||||
|
|
@ -151,6 +152,9 @@ export function VendorsTable({
|
|||
<Link href={`/inventory/vendors/${vendor.id}`} className="font-medium text-neutral-900 hover:text-primary-600 hover:underline">
|
||||
{vendor.name}
|
||||
</Link>
|
||||
{vendor.vendorId && (
|
||||
<span className="rounded bg-neutral-100 px-1.5 py-0.5 font-mono text-xs text-neutral-500">{vendor.vendorId}</span>
|
||||
)}
|
||||
{vendor.isVerified && (
|
||||
<span className="rounded-full bg-success-100 px-1.5 py-0.5 text-xs font-medium text-success-700">Verified</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { canPerformAction } from "@/lib/po-state-machine";
|
||||
import { canPerformAction, canCancel } from "@/lib/po-state-machine";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
import { notify } from "@/lib/notifier";
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
|
|
@ -113,3 +114,118 @@ export async function discardDraftPo(
|
|||
revalidatePath("/dashboard");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Cancel a PO ───────────────────────────────────────────────────────────────
|
||||
// MANAGER / SUPERUSER only, from any state, with a mandatory reason. A cancelled
|
||||
// PO drops out of every spend tracker (those filter on POST_APPROVAL_STATUSES /
|
||||
// explicit whitelists, none of which include CANCELLED).
|
||||
|
||||
export async function cancelPo({
|
||||
poId,
|
||||
reason,
|
||||
}: {
|
||||
poId: string;
|
||||
reason: string;
|
||||
}): Promise<{ ok: true } | { error: string }> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||
return { error: "You do not have permission to cancel purchase orders." };
|
||||
}
|
||||
|
||||
const trimmed = (reason ?? "").trim();
|
||||
if (!trimmed) return { error: "A cancellation reason is required." };
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({
|
||||
where: { id: poId },
|
||||
include: { submitter: true },
|
||||
});
|
||||
if (!po) return { error: "PO not found" };
|
||||
if (!canCancel(po.status, session.user.role)) {
|
||||
return {
|
||||
error: po.status === "CANCELLED"
|
||||
? "This purchase order is already cancelled."
|
||||
: "You cannot cancel this purchase order.",
|
||||
};
|
||||
}
|
||||
|
||||
await db.purchaseOrder.update({
|
||||
where: { id: poId },
|
||||
data: {
|
||||
status: "CANCELLED",
|
||||
cancelledAt: new Date(),
|
||||
cancellationReason: trimmed,
|
||||
actions: { create: { actionType: "CANCELLED", actorId: session.user.id, note: trimmed } },
|
||||
},
|
||||
});
|
||||
|
||||
// Notify the submitter and Accounts (they track spend).
|
||||
const accounts = await db.user.findMany({ where: { role: "ACCOUNTS", isActive: true } });
|
||||
const recipients = [po.submitter, ...accounts].filter(
|
||||
(u, i, arr) => arr.findIndex((x) => x.id === u.id) === i
|
||||
);
|
||||
await notify({ event: "PO_CANCELLED", po, recipients, note: trimmed });
|
||||
|
||||
revalidatePath(`/po/${poId}`);
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/history");
|
||||
revalidatePath("/my-orders");
|
||||
revalidatePath("/payments");
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ── Supersede a cancelled PO with an existing replacement PO ────────────────────
|
||||
// Links a cancelled PO to the existing PO that replaces it (by PO number). No
|
||||
// vessel/account/vendor match is enforced. The reciprocal "supersedes" link is
|
||||
// surfaced on the replacement via the schema self-relation.
|
||||
|
||||
export async function supersedePo({
|
||||
poId,
|
||||
replacementPoNumber,
|
||||
}: {
|
||||
poId: string;
|
||||
replacementPoNumber: string;
|
||||
}): Promise<{ ok: true } | { error: string }> {
|
||||
const session = await auth();
|
||||
if (!session?.user) return { error: "Unauthorized" };
|
||||
if (!hasPermission(session.user.role, "cancel_po")) {
|
||||
return { error: "You do not have permission to link a superseding purchase order." };
|
||||
}
|
||||
|
||||
const num = (replacementPoNumber ?? "").trim();
|
||||
if (!num) return { error: "Enter the PO number that supersedes this one." };
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({
|
||||
where: { id: poId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!po) return { error: "PO not found" };
|
||||
if (po.status !== "CANCELLED") {
|
||||
return { error: "Only a cancelled purchase order can be superseded." };
|
||||
}
|
||||
|
||||
const replacement = await db.purchaseOrder.findUnique({
|
||||
where: { poNumber: num },
|
||||
select: { id: true, poNumber: true },
|
||||
});
|
||||
if (!replacement) return { error: `No purchase order found with number "${num}".` };
|
||||
if (replacement.id === po.id) return { error: "A purchase order cannot supersede itself." };
|
||||
|
||||
await db.purchaseOrder.update({
|
||||
where: { id: poId },
|
||||
data: {
|
||||
supersededById: replacement.id,
|
||||
actions: {
|
||||
create: {
|
||||
actionType: "SUPERSEDED",
|
||||
actorId: session.user.id,
|
||||
note: `Superseded by ${replacement.poNumber}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
revalidatePath(`/po/${poId}`);
|
||||
revalidatePath(`/po/${replacement.id}`);
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ export default async function PoDetailPage({ params }: Props) {
|
|||
documents: { orderBy: { uploadedAt: "desc" } },
|
||||
actions: { include: { actor: true }, orderBy: { createdAt: "asc" } },
|
||||
receipt: true,
|
||||
supersededBy: { select: { id: true, poNumber: true } },
|
||||
supersedes: { select: { id: true, poNumber: true } },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const ROLE_LABELS: Record<string, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
export default async function ProfilePage() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import { NextRequest, NextResponse } from "next/server";
|
|||
import ExcelJS from "exceljs";
|
||||
import { TC_FIXED_LINE, TC_DEFAULTS } from "@/lib/validations/po";
|
||||
import { downloadBuffer } from "@/lib/storage";
|
||||
import { CANCELLED_WATERMARK_PNG_BASE64, CANCELLED_WATERMARK_W, CANCELLED_WATERMARK_H } from "@/lib/cancelled-watermark";
|
||||
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||
|
||||
// ── Company fallback constants (used when no company is linked to a PO) ──────
|
||||
|
||||
|
|
@ -31,12 +34,15 @@ function mimeForKey(key: string): string {
|
|||
return ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||
}
|
||||
|
||||
// Download a stored image and return it base64-encoded (or null if missing).
|
||||
async function fetchImage(key: string | null | undefined): Promise<{ base64: string; mime: string } | null> {
|
||||
interface EmbeddedImage { base64: string; mime: string; width: number; height: number }
|
||||
|
||||
// Download a stored image; return base64 + mime + pixel dimensions (or null if missing).
|
||||
async function fetchImage(key: string | null | undefined): Promise<EmbeddedImage | null> {
|
||||
if (!key) return null;
|
||||
const buf = await downloadBuffer(key);
|
||||
if (!buf) return null;
|
||||
return { base64: buf.toString("base64"), mime: mimeForKey(key) };
|
||||
const size = getImageSize(buf) ?? { width: 100, height: 100 };
|
||||
return { base64: buf.toString("base64"), mime: mimeForKey(key), width: size.width, height: size.height };
|
||||
}
|
||||
|
||||
// ── Route ─────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -65,9 +71,11 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Exports are only available for approved POs — manager approval is a prerequisite for a valid PO document.
|
||||
// Exports are available for approved POs (manager approval is a prerequisite for a valid PO
|
||||
// document) and for CANCELLED POs, which export with a diagonal "CANCELLED" watermark.
|
||||
// The submitter's signature is never embedded; only the approving manager's signature is used.
|
||||
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"];
|
||||
const EXPORTABLE_STATUSES = ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"];
|
||||
const isCancelled = po.status === "CANCELLED";
|
||||
if (!EXPORTABLE_STATUSES.includes(po.status)) {
|
||||
return NextResponse.json(
|
||||
{ error: "Export is only available for approved purchase orders." },
|
||||
|
|
@ -126,6 +134,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
// Fetch approver's signature for embedding in the document
|
||||
let signatureBase64: string | null = null;
|
||||
let signatureMime = "image/png";
|
||||
let signatureSize: { width: number; height: number } | null = null;
|
||||
if (approvalAction) {
|
||||
const approver = await db.user.findUnique({
|
||||
where: { id: approvalAction.actorId },
|
||||
|
|
@ -137,6 +146,7 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
signatureBase64 = buf.toString("base64");
|
||||
const ext = approver.signatureKey.split(".").pop()?.toLowerCase();
|
||||
signatureMime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "webp" ? "image/webp" : "image/png";
|
||||
signatureSize = getImageSize(buf) ?? { width: 360, height: 96 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -275,15 +285,15 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
ws.mergeCells("A4:I4");
|
||||
ws.getRow(4).border = { top: thin(), bottom: thin() };
|
||||
|
||||
// ══ Company logo (floats top-left over the header, columns A-B) ══════════
|
||||
// ══ Company logo (floats top-left over the header; aspect preserved) ═════
|
||||
if (logoImg) {
|
||||
const logoId = wb.addImage({
|
||||
base64: logoImg.base64,
|
||||
extension: logoImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||
});
|
||||
ws.addImage(logoId, {
|
||||
tl: { col: 0.1, row: 0.1 } as unknown as ExcelJS.Anchor,
|
||||
br: { col: 1.9, row: 2.9 } as unknown as ExcelJS.Anchor,
|
||||
tl: { col: 0.15, row: 0.2 } as unknown as ExcelJS.Anchor,
|
||||
ext: scaleToBox(logoImg, 96, 52),
|
||||
editAs: "oneCell",
|
||||
});
|
||||
}
|
||||
|
|
@ -450,16 +460,47 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
ws.getRow(SIG_ROW + 1).height = 14;
|
||||
ws.getRow(SIG_ROW + 2).height = 14;
|
||||
|
||||
// Left sig block (approver — the manager who authorized the PO)
|
||||
if (signatureBase64) {
|
||||
// Left signatory block (cols A-D). Position images by absolute pixels via native
|
||||
// EMU offsets — ExcelJS's fractional-column anchors don't map cleanly to pixels.
|
||||
const EMU = 9525; // EMU per pixel
|
||||
const COL_PX = [22, 4, 28, 15, 8, 15, 15, 8, 16].map((w) => Math.round(w * 7 + 5));
|
||||
const SIG_BLOCK_PX = COL_PX[0] + COL_PX[1] + COL_PX[2] + COL_PX[3]; // A-D
|
||||
const anchorAt = (leftPx: number, row: number) => {
|
||||
let x = 0;
|
||||
for (let c = 0; c < COL_PX.length - 1; c++) {
|
||||
if (leftPx < x + COL_PX[c]) {
|
||||
return { nativeCol: c, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
||||
}
|
||||
x += COL_PX[c];
|
||||
}
|
||||
return { nativeCol: COL_PX.length - 1, nativeColOff: Math.round((leftPx - x) * EMU), nativeRow: row, nativeRowOff: 0 } as unknown as ExcelJS.Anchor;
|
||||
};
|
||||
|
||||
const sigExt = signatureBase64 ? scaleToBox(signatureSize ?? { width: 360, height: 96 }, 165, 44) : null;
|
||||
const stampExt = stampImg ? scaleToBox(stampImg, 80, 66) : null;
|
||||
// Signature centred over the name; stamp to its RIGHT with a gap (no overlap).
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: SIG_BLOCK_PX, sig: sigExt, stamp: stampExt });
|
||||
|
||||
// Stamp / seal — drawn FIRST so it layers BEHIND the signature if they ever touch.
|
||||
if (stampImg && stampExt && stampLeft != null) {
|
||||
const stampId = wb.addImage({
|
||||
base64: stampImg.base64,
|
||||
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||
});
|
||||
ws.addImage(stampId, {
|
||||
tl: anchorAt(stampLeft, SIG_ROW - 1),
|
||||
ext: stampExt,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
}
|
||||
|
||||
// Approver signature — drawn AFTER the stamp (on top), centred over the name.
|
||||
if (signatureBase64 && sigExt && sigLeft != null) {
|
||||
const imgType = signatureMime === "image/jpeg" ? "jpeg" : "png";
|
||||
const imgId = wb.addImage({ base64: signatureBase64, extension: imgType });
|
||||
// Span the image across columns A-D in the sig row
|
||||
ws.addImage(imgId, {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
tl: { col: 0, row: SIG_ROW - 1 } as any,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
br: { col: 4, row: SIG_ROW } as any,
|
||||
tl: anchorAt(Math.max(0, sigLeft), SIG_ROW - 1),
|
||||
ext: sigExt,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
sc(SIG_ROW, 1, "", { border: { top: thin(), left: thin(), right: thin() } });
|
||||
|
|
@ -478,19 +519,6 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
ws.getRow(SIG_ROW + 2).height = 14;
|
||||
ws.getRow(SIG_ROW + 3).height = 14;
|
||||
|
||||
// Company stamp / seal — overlays the right of the approver's signatory block (cols C-D)
|
||||
if (stampImg) {
|
||||
const stampId = wb.addImage({
|
||||
base64: stampImg.base64,
|
||||
extension: stampImg.mime === "image/jpeg" ? "jpeg" : "png",
|
||||
});
|
||||
ws.addImage(stampId, {
|
||||
tl: { col: 2.2, row: SIG_ROW - 1 } as unknown as ExcelJS.Anchor,
|
||||
br: { col: 3.9, row: SIG_ROW + 2 } as unknown as ExcelJS.Anchor,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
}
|
||||
|
||||
// Right sig block (vendor)
|
||||
const vName = po.vendor?.name ?? "";
|
||||
sc(SIG_ROW, 6, vName, { font: fBold, border: { top: thin(), left: thin(), right: thin() }, align: alignC });
|
||||
|
|
@ -508,6 +536,19 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
for (let c = 1; c <= 9; c++) sc(BAR_ROW, c, "", { fill: barFill });
|
||||
ws.mergeCells(`A${BAR_ROW}:I${BAR_ROW}`);
|
||||
|
||||
// ══ Cancelled watermark — diagonal "CANCELLED" centred over the sheet ════
|
||||
// Pixel-sized (aspect preserved) so the text spans the page like the PDF,
|
||||
// rather than being stretched/squished by a cell-range anchor.
|
||||
if (isCancelled) {
|
||||
const wmId = wb.addImage({ base64: CANCELLED_WATERMARK_PNG_BASE64, extension: "png" });
|
||||
const ext = scaleToBox({ width: CANCELLED_WATERMARK_W, height: CANCELLED_WATERMARK_H }, 880, 720);
|
||||
ws.addImage(wmId, {
|
||||
tl: { col: 0.15, row: 5 } as unknown as ExcelJS.Anchor,
|
||||
ext,
|
||||
editAs: "oneCell",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Serialise ─────────────────────────────────────────────────────────
|
||||
const buf = await wb.xlsx.writeBuffer();
|
||||
const slug = po.poNumber.replace(/\//g, "-");
|
||||
|
|
@ -665,6 +706,24 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
background: ${BRAND_BAR_COLOR};
|
||||
}
|
||||
|
||||
/* ── Cancelled watermark ── */
|
||||
.cancelled-watermark {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%) rotate(-35deg);
|
||||
font-size: 96pt;
|
||||
font-weight: 800;
|
||||
letter-spacing: 8px;
|
||||
color: rgba(200, 0, 0, 0.18);
|
||||
border: 6px solid rgba(200, 0, 0, 0.18);
|
||||
padding: 8px 32px;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print { display: none; }
|
||||
body { margin: 8mm 10mm; }
|
||||
|
|
@ -674,6 +733,8 @@ export async function GET(request: NextRequest, { params }: Props) {
|
|||
</head>
|
||||
<body>
|
||||
|
||||
${isCancelled ? `<div class="cancelled-watermark">CANCELLED</div>` : ""}
|
||||
|
||||
<div class="no-print" style="margin-bottom:8px">
|
||||
<button onclick="window.print()" style="padding:5px 14px;font-size:11px;cursor:pointer;border:1px solid #999;border-radius:4px">
|
||||
🖨 Print / Save as PDF
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ const PO_STATUS_LABELS: Record<string, string> = {
|
|||
DRAFT: "Draft", SUBMITTED: "Submitted", MGR_REVIEW: "Pending Approval",
|
||||
VENDOR_ID_PENDING: "Vendor ID Pending", EDITS_REQUESTED: "Edits Requested",
|
||||
REJECTED: "Rejected", MGR_APPROVED: "Approved", SENT_FOR_PAYMENT: "Sent for Payment",
|
||||
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed",
|
||||
PAID_DELIVERED: "Paid / Delivered", CLOSED: "Closed", CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const ROLE_LABELS: Record<Role, string> = {
|
|||
SUPERUSER: "SuperUser",
|
||||
AUDITOR: "Auditor",
|
||||
ADMIN: "Admin",
|
||||
SITE_STAFF: "Site Staff",
|
||||
};
|
||||
|
||||
const CART_ROLES: Role[] = ["TECHNICAL", "MANNING", "SUPERUSER", "MANAGER"];
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { INVENTORY_ENABLED } from "@/lib/feature-flags";
|
||||
import { INVENTORY_ENABLED, CREWING_ENABLED } from "@/lib/feature-flags";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
ShoppingCart,
|
||||
UserCircle,
|
||||
ShieldCheck,
|
||||
Network,
|
||||
} from "lucide-react";
|
||||
import type { Role } from "@prisma/client";
|
||||
|
||||
|
|
@ -67,11 +68,22 @@ const PURCHASING_MGMT: NavItem[] = [
|
|||
|
||||
const PURCHASING_ITEMS: NavItem[] = [...PURCHASING_STAFF, ...PURCHASING_MGMT];
|
||||
|
||||
// ── Crewing section (feature-flagged) ─────────────────────────────────────────
|
||||
// Scaffold for the Crewing module. Phase 1 (Foundations) adds no top-level items
|
||||
// here — its only screen, "Ranks & documents", lives under Administration. Later
|
||||
// phases append Requisitions / Candidates / Crew / Leave / Attendance /
|
||||
// Verification with their per-role visibility (see Crewing-Implementation-Spec §7).
|
||||
const CREWING_ITEMS: NavItem[] = [];
|
||||
|
||||
// ── Administration section ────────────────────────────────────────────────────
|
||||
// Vendors shown to MANAGER / ACCOUNTS under their own Administration header
|
||||
const MANAGER_ADMIN_ITEMS: NavItem[] = [
|
||||
{ href: "/admin/vendors", label: "Vendors", icon: Store, roles: ["MANAGER", "ACCOUNTS", "ADMIN"] },
|
||||
{ href: "/admin/products", label: "Items", icon: Package, roles: ["MANAGER", "ADMIN"] },
|
||||
// Crewing reference data — gated by the crewing flag; held by manage_ranks (MGR/ADMIN).
|
||||
...(CREWING_ENABLED
|
||||
? [{ href: "/admin/ranks", label: "Ranks & documents", icon: Network, roles: ["MANAGER", "ADMIN"] as Role[] }]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Full Administration section (ADMIN only)
|
||||
|
|
@ -90,6 +102,7 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
|||
|
||||
const visibleMain = NAV_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visiblePurchasing = PURCHASING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visibleCrewing = CREWING_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
const visibleMgrAdmin = MANAGER_ADMIN_ITEMS.filter((i) => !i.roles || i.roles.includes(userRole));
|
||||
|
||||
return (
|
||||
|
|
@ -115,6 +128,16 @@ export function Sidebar({ userRole }: { userRole: Role }) {
|
|||
</>
|
||||
)}
|
||||
|
||||
{/* Crewing — only renders once the flag is on and items exist (later phases) */}
|
||||
{visibleCrewing.length > 0 && (
|
||||
<>
|
||||
<SectionHeader label="Crewing" />
|
||||
{visibleCrewing.map((item) => (
|
||||
<NavLink key={item.href} item={item} pathname={pathname} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Vendors under Administration for MANAGER / ACCOUNTS */}
|
||||
{!isAdmin && visibleMgrAdmin.length > 0 && (
|
||||
<>
|
||||
|
|
|
|||
158
App/components/po/cancel-po-controls.tsx
Normal file
158
App/components/po/cancel-po-controls.tsx
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
|
||||
|
||||
// ── Cancel PO button + confirmation modal ──────────────────────────────────────
|
||||
// The manager must type the word "cancel" and provide a reason before the action
|
||||
// is enabled — a deliberate friction step for an irreversible, terminal action.
|
||||
|
||||
export function CancelPoButton({ poId, poNumber }: { poId: string; poNumber: string }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [reason, setReason] = useState("");
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const confirmed = confirmText.trim().toLowerCase() === "cancel";
|
||||
const canSubmit = confirmed && reason.trim().length > 0 && !pending;
|
||||
|
||||
function close() {
|
||||
if (pending) return;
|
||||
setOpen(false);
|
||||
setReason("");
|
||||
setConfirmText("");
|
||||
setError("");
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!canSubmit) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
const result = await cancelPo({ poId, reason: reason.trim() });
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setPending(false);
|
||||
} else {
|
||||
setPending(false);
|
||||
setOpen(false);
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="rounded-lg bg-danger px-3 py-2 text-sm font-semibold text-white hover:bg-danger-700 transition-colors"
|
||||
>
|
||||
Cancel PO
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={close}>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className="text-lg font-semibold text-neutral-900">Cancel {poNumber}?</h2>
|
||||
<p className="mt-1.5 text-sm text-neutral-600">
|
||||
This marks the purchase order as <strong>cancelled</strong> and removes its value from
|
||||
all spend trackers and graphs. This cannot be undone.
|
||||
</p>
|
||||
|
||||
<label className="mt-4 block text-xs font-medium text-neutral-700">
|
||||
Reason for cancellation <span className="text-danger">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
rows={3}
|
||||
autoFocus
|
||||
placeholder="e.g. Duplicate order — superseded by a corrected PO"
|
||||
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
|
||||
/>
|
||||
|
||||
<label className="mt-3 block text-xs font-medium text-neutral-700">
|
||||
Type <span className="font-mono font-semibold">cancel</span> to confirm
|
||||
</label>
|
||||
<input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="cancel"
|
||||
className="mt-1 w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-danger focus:outline-none focus:ring-2 focus:ring-danger/20"
|
||||
/>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
disabled={pending}
|
||||
className="rounded-lg border border-neutral-300 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60"
|
||||
>
|
||||
Keep PO
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
disabled={!canSubmit}
|
||||
className="rounded-lg bg-danger px-4 py-2 text-sm font-semibold text-white hover:bg-danger-700 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Cancelling…" : "Cancel this PO"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Supersede: link a cancelled PO to the existing PO that replaces it ──────────
|
||||
|
||||
export function SupersedeForm({ poId }: { poId: string }) {
|
||||
const router = useRouter();
|
||||
const [value, setValue] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleLink(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!value.trim()) return;
|
||||
setPending(true);
|
||||
setError("");
|
||||
const result = await supersedePo({ poId, replacementPoNumber: value.trim() });
|
||||
if ("error" in result) {
|
||||
setError(result.error);
|
||||
setPending(false);
|
||||
} else {
|
||||
setPending(false);
|
||||
setValue("");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleLink} className="mt-2 flex flex-wrap items-start gap-2">
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="Replacement PO number, e.g. PMS/HNR1/9001/2026-27"
|
||||
className="min-w-[260px] flex-1 rounded-lg border border-neutral-300 px-3 py-2 text-sm font-mono focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || !value.trim()}
|
||||
className="rounded-lg border border-primary-200 bg-primary-50 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-100 disabled:opacity-50"
|
||||
>
|
||||
{pending ? "Linking…" : "Link replacement"}
|
||||
</button>
|
||||
{error && <p className="w-full text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { PoStatusBadge } from "@/components/po/po-status-badge";
|
|||
import { LineItemsEditor } from "@/components/po/po-line-items-editor";
|
||||
import { DiscardDraftButton } from "@/components/po/discard-draft-button";
|
||||
import { SubmitDraftButton } from "@/components/po/submit-draft-button";
|
||||
import { CancelPoButton, SupersedeForm } from "@/components/po/cancel-po-controls";
|
||||
import { formatCurrency, formatDate, formatDateTime } from "@/lib/utils";
|
||||
import { generateDownloadUrl } from "@/lib/storage";
|
||||
import { groupAttachments } from "@/lib/attachments";
|
||||
|
|
@ -40,6 +41,10 @@ type PoWithRelations = {
|
|||
approvedAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
closedAt: Date | null;
|
||||
cancelledAt?: Date | null;
|
||||
cancellationReason?: string | null;
|
||||
supersededBy?: { id: string; poNumber: string } | null;
|
||||
supersedes?: { id: string; poNumber: string }[];
|
||||
submitter: { id: string; name: string; email: string };
|
||||
vessel: { id: string; name: string };
|
||||
account: { id: string; name: string; code: string };
|
||||
|
|
@ -92,6 +97,8 @@ const ACTION_LABELS: Record<string, string> = {
|
|||
CLOSED: "Closed",
|
||||
MANAGER_LINE_EDIT: "Manager amended line items",
|
||||
PRODUCT_PRICE_UPDATED: "Product prices updated",
|
||||
CANCELLED: "Cancelled",
|
||||
SUPERSEDED: "Superseded",
|
||||
};
|
||||
|
||||
export async function PoDetail({ po, currentUserId, currentRole, readOnly = false }: Props) {
|
||||
|
|
@ -203,8 +210,8 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
!readOnly && (
|
||||
<DiscardDraftButton poId={po.id} />
|
||||
)}
|
||||
{/* Export buttons — only available once the PO has been approved by a manager */}
|
||||
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED"].includes(po.status) && (<>
|
||||
{/* Export buttons — available once approved, and for cancelled POs (watermarked) */}
|
||||
{["MGR_APPROVED", "SENT_FOR_PAYMENT", "PARTIALLY_PAID", "PAID_DELIVERED", "PARTIALLY_CLOSED", "CLOSED", "CANCELLED"].includes(po.status) && (<>
|
||||
<a
|
||||
href={`/api/po/${po.id}/export?format=pdf`}
|
||||
target="_blank"
|
||||
|
|
@ -220,9 +227,59 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
|
|||
Export XLSX
|
||||
</a>
|
||||
</>)}
|
||||
{/* Cancel — MANAGER / SUPERUSER, from any non-cancelled state */}
|
||||
{po.status !== "CANCELLED" &&
|
||||
["MANAGER", "SUPERUSER"].includes(currentRole) &&
|
||||
!readOnly && (
|
||||
<CancelPoButton poId={po.id} poNumber={po.poNumber} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cancelled banner — reason + supersede link (and the reciprocal "supersedes") */}
|
||||
{po.status === "CANCELLED" && (
|
||||
<div className="rounded-lg border border-danger-100 bg-danger-50 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-danger-700">
|
||||
Cancelled{po.cancelledAt ? ` on ${formatDate(po.cancelledAt)}` : ""}
|
||||
</p>
|
||||
{po.cancellationReason && (
|
||||
<p className="mt-0.5 text-sm text-danger-700">Reason: {po.cancellationReason}</p>
|
||||
)}
|
||||
<div className="mt-2 text-sm text-danger-700">
|
||||
{po.supersededBy ? (
|
||||
<p>
|
||||
Superseded by{" "}
|
||||
<Link href={`/po/${po.supersededBy.id}`} className="font-mono font-medium underline">
|
||||
{po.supersededBy.poNumber}
|
||||
</Link>
|
||||
</p>
|
||||
) : ["MANAGER", "SUPERUSER"].includes(currentRole) && !readOnly ? (
|
||||
<div>
|
||||
<p className="text-danger-700/80">Optionally link the PO that replaces this one:</p>
|
||||
<SupersedeForm poId={po.id} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reciprocal "supersedes" link — shown on the replacement PO */}
|
||||
{po.supersedes && po.supersedes.length > 0 && (
|
||||
<div className="rounded-lg border border-neutral-200 bg-neutral-50 px-4 py-3">
|
||||
<p className="text-sm text-neutral-700">
|
||||
Supersedes{" "}
|
||||
{po.supersedes.map((s, i) => (
|
||||
<span key={s.id}>
|
||||
{i > 0 && ", "}
|
||||
<Link href={`/po/${s.id}`} className="font-mono font-medium text-primary-600 underline">
|
||||
{s.poNumber}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manager note banner */}
|
||||
{po.managerNote && (
|
||||
<div className="rounded-lg border border-warning-100 bg-warning-50 px-4 py-3">
|
||||
|
|
|
|||
6
App/lib/cancelled-watermark.ts
Normal file
6
App/lib/cancelled-watermark.ts
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -4,7 +4,15 @@
|
|||
*
|
||||
* NEXT_PUBLIC_INVENTORY_ENABLED=false → hides inventory tracking (site qty/consumption)
|
||||
* Vendor list, product catalogue, and cart remain available for PO creation regardless.
|
||||
*
|
||||
* NEXT_PUBLIC_CREWING_ENABLED=true → exposes the Crewing module (crew/ranks/requisitions
|
||||
* etc.). Opt-in (off unless explicitly "true") because the feature is built incrementally;
|
||||
* keeping it dark by default leaves production unchanged. See lib/permissions.ts (§6 matrix)
|
||||
* and wiki Crewing-Implementation-Spec.
|
||||
*/
|
||||
|
||||
export const INVENTORY_ENABLED =
|
||||
process.env.NEXT_PUBLIC_INVENTORY_ENABLED !== "false";
|
||||
|
||||
export const CREWING_ENABLED =
|
||||
process.env.NEXT_PUBLIC_CREWING_ENABLED === "true";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export const ROLE_PREFIX: Record<string, string> = {
|
|||
SUPERUSER: "SUP",
|
||||
AUDITOR: "AUD",
|
||||
ADMIN: "ADM",
|
||||
SITE_STAFF: "SIT",
|
||||
};
|
||||
|
||||
/** Find max existing number for prefix and return prefix-(max+1), zero-padded to 3 digits */
|
||||
|
|
|
|||
46
App/lib/image-size.ts
Normal file
46
App/lib/image-size.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Image dimension helpers used to size XLSX floating images by pixels with the
|
||||
// aspect ratio preserved. ExcelJS's two-cell (tl/br) anchoring otherwise stretches
|
||||
// an image to fill a cell range, which distorts logos / signatures / stamps.
|
||||
|
||||
/** Read pixel dimensions from a PNG / JPEG / WebP buffer (header parse, no deps). */
|
||||
export function getImageSize(buf: Buffer): { width: number; height: number } | null {
|
||||
// PNG — IHDR width/height at byte offsets 16 / 20
|
||||
if (buf.length >= 24 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47) {
|
||||
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
// JPEG — scan segments for a Start-Of-Frame marker
|
||||
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
||||
let o = 2;
|
||||
while (o + 9 < buf.length) {
|
||||
if (buf[o] !== 0xff) { o++; continue; }
|
||||
const m = buf[o + 1];
|
||||
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
|
||||
return { height: buf.readUInt16BE(o + 5), width: buf.readUInt16BE(o + 7) };
|
||||
}
|
||||
o += 2 + buf.readUInt16BE(o + 2);
|
||||
}
|
||||
}
|
||||
// WebP — RIFF container, VP8 / VP8L / VP8X
|
||||
if (buf.length >= 30 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") {
|
||||
const fmt = buf.toString("ascii", 12, 16);
|
||||
if (fmt === "VP8 ") return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
||||
if (fmt === "VP8L") { const b = buf.readUInt32LE(21); return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }; }
|
||||
if (fmt === "VP8X") {
|
||||
return {
|
||||
width: 1 + ((buf[24] | (buf[25] << 8) | (buf[26] << 16)) & 0xffffff),
|
||||
height: 1 + ((buf[27] | (buf[28] << 8) | (buf[29] << 16)) & 0xffffff),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Scale natural dimensions to fit within a max box (px), preserving aspect ratio. */
|
||||
export function scaleToBox(
|
||||
natural: { width: number; height: number },
|
||||
maxW: number,
|
||||
maxH: number
|
||||
): { width: number; height: number } {
|
||||
const s = Math.min(maxW / natural.width, maxH / natural.height);
|
||||
return { width: Math.round(natural.width * s), height: Math.round(natural.height * s) };
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ export type NotificationEvent =
|
|||
| "PO_APPROVED"
|
||||
| "PO_APPROVED_WITH_NOTE"
|
||||
| "PO_REJECTED"
|
||||
| "PO_CANCELLED"
|
||||
| "EDITS_REQUESTED"
|
||||
| "VENDOR_ID_REQUESTED"
|
||||
| "VENDOR_ID_PROVIDED"
|
||||
|
|
@ -119,6 +120,9 @@ function buildInAppBody(
|
|||
case "PO_REJECTED":
|
||||
return `${pn} rejected`;
|
||||
|
||||
case "PO_CANCELLED":
|
||||
return `${pn} has been cancelled`;
|
||||
|
||||
case "EDITS_REQUESTED":
|
||||
return `Edits requested on ${pn}`;
|
||||
|
||||
|
|
@ -215,6 +219,7 @@ function buildSubject(event: NotificationEvent, poNumber: string): string | null
|
|||
PO_APPROVED: `${base} has been approved`,
|
||||
PO_APPROVED_WITH_NOTE: `${base} has been approved`,
|
||||
PO_REJECTED: `${base} has been rejected`,
|
||||
PO_CANCELLED: `${base} has been cancelled`,
|
||||
EDITS_REQUESTED: `Edits requested on ${base}`,
|
||||
VENDOR_ID_REQUESTED: `Vendor ID needed for ${base}`,
|
||||
VENDOR_ID_PROVIDED: `Vendor ID provided for ${base}`,
|
||||
|
|
@ -245,6 +250,8 @@ function buildEmailBody(
|
|||
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#16a34a;font-weight:600;">approved</span>.${noteHtml}`;
|
||||
case "PO_REJECTED":
|
||||
return `Your purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">rejected</span>.${noteHtml}`;
|
||||
case "PO_CANCELLED":
|
||||
return `Purchase order <strong>${po.poNumber}</strong> has been <span style="color:#dc2626;font-weight:600;">cancelled</span>.${noteHtml}`;
|
||||
case "EDITS_REQUESTED":
|
||||
return `Edits have been requested on <strong>${po.poNumber}</strong>. Please update the order and resubmit.${noteHtml}`;
|
||||
case "VENDOR_ID_REQUESTED":
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export type Permission =
|
|||
| "view_all_pos"
|
||||
| "approve_po"
|
||||
| "reject_po"
|
||||
| "cancel_po"
|
||||
| "request_edits"
|
||||
| "request_vendor_id"
|
||||
| "process_payment"
|
||||
|
|
@ -19,9 +20,42 @@ export type Permission =
|
|||
| "create_vendor"
|
||||
| "manage_vessels_accounts"
|
||||
| "manage_products"
|
||||
| "manage_sites";
|
||||
| "manage_sites"
|
||||
// ── Crewing (feature-flagged) — mirrors Crewing-Implementation-Spec §6 ──────
|
||||
| "raise_requisition"
|
||||
| "request_relief_cover"
|
||||
| "convert_relief_to_requisition"
|
||||
| "cancel_requisition"
|
||||
| "view_requisitions"
|
||||
| "manage_candidates"
|
||||
| "record_reference_check"
|
||||
| "record_interview_result"
|
||||
| "request_interview_waiver"
|
||||
| "approve_interview_waiver"
|
||||
| "approve_salary_structure"
|
||||
| "select_candidate"
|
||||
| "onboard_crew"
|
||||
| "sign_off_crew"
|
||||
| "view_crew_records"
|
||||
| "upload_crew_records"
|
||||
| "issue_ppe"
|
||||
| "apply_leave"
|
||||
| "decide_leave"
|
||||
| "record_attendance"
|
||||
| "view_attendance"
|
||||
| "verify_site_records"
|
||||
| "verify_bank_epf"
|
||||
| "raise_appraisal"
|
||||
| "verify_appraisal"
|
||||
| "approve_appraisal"
|
||||
| "generate_wage_report"
|
||||
| "approve_wage_report"
|
||||
| "view_wage_report"
|
||||
| "manage_ranks";
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
// Purchasing / admin permissions (the original PPMS matrix). SITE_STAFF is a
|
||||
// crewing-only role and holds no purchasing permissions.
|
||||
const PO_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
TECHNICAL: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
|
||||
MANNING: ["create_po", "submit_po", "edit_own_draft_po", "view_own_pos", "confirm_receipt", "create_vendor"],
|
||||
ACCOUNTS: ["view_all_pos", "process_payment", "manage_vendors", "create_vendor"],
|
||||
|
|
@ -33,6 +67,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"view_all_pos",
|
||||
"approve_po",
|
||||
"reject_po",
|
||||
"cancel_po",
|
||||
"request_edits",
|
||||
"request_vendor_id",
|
||||
"view_analytics",
|
||||
|
|
@ -53,6 +88,7 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"view_all_pos",
|
||||
"approve_po",
|
||||
"reject_po",
|
||||
"cancel_po",
|
||||
"request_edits",
|
||||
"request_vendor_id",
|
||||
"process_payment",
|
||||
|
|
@ -74,8 +110,115 @@ const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
|||
"manage_products",
|
||||
"manage_sites",
|
||||
],
|
||||
SITE_STAFF: [],
|
||||
};
|
||||
|
||||
// Crewing permissions — a verbatim transcription of the §6 grant matrix in
|
||||
// wiki Crewing-Implementation-Spec. Gating these is harmless until the screens
|
||||
// land (the module is behind NEXT_PUBLIC_CREWING_ENABLED). Notes from the spec:
|
||||
// MPO (MANNING) has NO attendance/leave; decide_leave/approve_* and selection are
|
||||
// Manager-only; manage_ranks is Manager + Admin (not SuperUser).
|
||||
const CREWING_ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
TECHNICAL: [],
|
||||
SITE_STAFF: [
|
||||
"request_relief_cover",
|
||||
"sign_off_crew",
|
||||
"view_crew_records",
|
||||
"upload_crew_records",
|
||||
"issue_ppe",
|
||||
"apply_leave",
|
||||
"record_attendance",
|
||||
"view_attendance",
|
||||
"raise_appraisal",
|
||||
],
|
||||
MANNING: [
|
||||
"raise_requisition",
|
||||
"convert_relief_to_requisition",
|
||||
"cancel_requisition",
|
||||
"view_requisitions",
|
||||
"manage_candidates",
|
||||
"record_reference_check",
|
||||
"record_interview_result",
|
||||
"request_interview_waiver",
|
||||
"onboard_crew",
|
||||
"sign_off_crew",
|
||||
"view_crew_records",
|
||||
"upload_crew_records",
|
||||
"issue_ppe",
|
||||
"verify_site_records",
|
||||
"verify_appraisal",
|
||||
],
|
||||
ACCOUNTS: ["view_crew_records", "verify_bank_epf", "view_wage_report"],
|
||||
MANAGER: [
|
||||
"raise_requisition",
|
||||
"convert_relief_to_requisition",
|
||||
"cancel_requisition",
|
||||
"view_requisitions",
|
||||
"manage_candidates",
|
||||
"record_reference_check",
|
||||
"record_interview_result",
|
||||
"approve_interview_waiver",
|
||||
"approve_salary_structure",
|
||||
"select_candidate",
|
||||
"onboard_crew",
|
||||
"sign_off_crew",
|
||||
"view_crew_records",
|
||||
"upload_crew_records",
|
||||
"issue_ppe",
|
||||
"apply_leave",
|
||||
"decide_leave",
|
||||
"view_attendance",
|
||||
"verify_site_records",
|
||||
"raise_appraisal",
|
||||
"verify_appraisal",
|
||||
"approve_appraisal",
|
||||
"generate_wage_report",
|
||||
"approve_wage_report",
|
||||
"view_wage_report",
|
||||
"manage_ranks",
|
||||
],
|
||||
SUPERUSER: [
|
||||
"raise_requisition",
|
||||
"request_relief_cover",
|
||||
"convert_relief_to_requisition",
|
||||
"cancel_requisition",
|
||||
"view_requisitions",
|
||||
"manage_candidates",
|
||||
"record_reference_check",
|
||||
"record_interview_result",
|
||||
"request_interview_waiver",
|
||||
"approve_interview_waiver",
|
||||
"approve_salary_structure",
|
||||
"select_candidate",
|
||||
"onboard_crew",
|
||||
"sign_off_crew",
|
||||
"view_crew_records",
|
||||
"upload_crew_records",
|
||||
"issue_ppe",
|
||||
"apply_leave",
|
||||
"decide_leave",
|
||||
"record_attendance",
|
||||
"view_attendance",
|
||||
"verify_site_records",
|
||||
"verify_bank_epf",
|
||||
"raise_appraisal",
|
||||
"verify_appraisal",
|
||||
"approve_appraisal",
|
||||
"generate_wage_report",
|
||||
"approve_wage_report",
|
||||
"view_wage_report",
|
||||
],
|
||||
AUDITOR: ["view_requisitions", "view_crew_records", "view_attendance", "view_wage_report"],
|
||||
ADMIN: ["view_requisitions", "view_crew_records", "view_wage_report", "manage_ranks"],
|
||||
};
|
||||
|
||||
const ROLE_PERMISSIONS: Record<Role, Permission[]> = Object.fromEntries(
|
||||
(Object.keys(PO_ROLE_PERMISSIONS) as Role[]).map((role) => [
|
||||
role,
|
||||
[...PO_ROLE_PERMISSIONS[role], ...CREWING_ROLE_PERMISSIONS[role]],
|
||||
])
|
||||
) as Record<Role, Permission[]>;
|
||||
|
||||
export function hasPermission(role: Role, permission: Permission): boolean {
|
||||
return ROLE_PERMISSIONS[role]?.includes(permission) ?? false;
|
||||
}
|
||||
|
|
|
|||
32
App/lib/po-export-layout.ts
Normal file
32
App/lib/po-export-layout.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Geometry for the exported PO's left signatory block (cols A-D).
|
||||
// The approver signature is centred over the name; the company stamp/seal sits to
|
||||
// its RIGHT with a gap so it never overlays the signature or name — important
|
||||
// because uploaded signatures/stamps aren't always transparent PNGs.
|
||||
|
||||
export interface Size { width: number; height: number }
|
||||
|
||||
export interface SignatoryLayout {
|
||||
sigLeft: number | null; // px from the block's left edge, or null when no signature
|
||||
stampLeft: number | null; // px from the block's left edge, or null when no stamp
|
||||
}
|
||||
|
||||
export function signatoryLayout(opts: {
|
||||
blockPx: number;
|
||||
sig: Size | null;
|
||||
stamp: Size | null;
|
||||
gap?: number;
|
||||
}): SignatoryLayout {
|
||||
const gap = opts.gap ?? 10;
|
||||
const sigLeft = opts.sig ? Math.round((opts.blockPx - opts.sig.width) / 2) : null; // centred
|
||||
|
||||
let stampLeft: number | null = null;
|
||||
if (opts.stamp) {
|
||||
stampLeft =
|
||||
sigLeft != null && opts.sig
|
||||
? Math.min(opts.blockPx - opts.stamp.width, sigLeft + opts.sig.width + gap) // clear of the signature
|
||||
: opts.blockPx - opts.stamp.width - 6; // no signature → right-align in the block
|
||||
stampLeft = Math.max(0, stampLeft);
|
||||
}
|
||||
|
||||
return { sigLeft, stampLeft };
|
||||
}
|
||||
|
|
@ -187,3 +187,15 @@ export function getAvailableActions(status: POStatus, role: Role): POAction[] {
|
|||
export function requiresNote(from: POStatus, action: POAction): boolean {
|
||||
return getTransition(from, action)?.requiresNote ?? false;
|
||||
}
|
||||
|
||||
// ── Cancellation ──────────────────────────────────────────────────────────────
|
||||
// Cancellation is orthogonal to the normal lifecycle: a PO can be cancelled from
|
||||
// ANY state (except when it is already cancelled), by a MANAGER or SUPERUSER, and
|
||||
// always requires a reason. It is modelled separately from TRANSITIONS so it does
|
||||
// not have to be enumerated on every source state.
|
||||
|
||||
export const CANCEL_ROLES: Role[] = ["MANAGER", "SUPERUSER"];
|
||||
|
||||
export function canCancel(from: POStatus, role: Role): boolean {
|
||||
return from !== "CANCELLED" && CANCEL_ROLES.includes(role);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,30 @@ export function formatCurrency(amount: number | string, currency = "INR"): strin
|
|||
);
|
||||
}
|
||||
|
||||
// Compact INR formatter using the Indian short scale (lakh = 1e5, crore = 1e7).
|
||||
// Produces readable abbreviations for dashboard stat cards, e.g. ₹2 Cr, ₹49 L,
|
||||
// ₹75 K, ₹500. Values are rounded to at most 2 decimals with trailing zeros
|
||||
// trimmed (₹2.5 Cr, not ₹2.50 Cr). Negative amounts keep their sign.
|
||||
export function formatCompactINR(amount: number | string): string {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return "₹0";
|
||||
|
||||
const sign = n < 0 ? "-" : "";
|
||||
const abs = Math.abs(n);
|
||||
|
||||
const format = (value: number, suffix: string) => {
|
||||
const rounded = Math.round(value * 100) / 100;
|
||||
// Trim trailing zeros: 2 -> "2", 2.5 -> "2.5", 2.05 -> "2.05".
|
||||
const text = rounded.toFixed(2).replace(/\.?0+$/, "");
|
||||
return `${sign}₹${text}${suffix}`;
|
||||
};
|
||||
|
||||
if (abs >= 1e7) return format(abs / 1e7, " Cr");
|
||||
if (abs >= 1e5) return format(abs / 1e5, " L");
|
||||
if (abs >= 1e3) return format(abs / 1e3, " K");
|
||||
return format(abs, "");
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | string): string {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
|
|
@ -51,6 +75,7 @@ export const PO_STATUS_LABELS: Record<POStatus, string> = {
|
|||
PAID_DELIVERED: "Paid",
|
||||
PARTIALLY_CLOSED: "Partially Received",
|
||||
CLOSED: "Closed",
|
||||
CANCELLED: "Cancelled",
|
||||
};
|
||||
|
||||
// Statuses a PO can be in once it has received manager approval. A PO keeps its
|
||||
|
|
@ -86,4 +111,5 @@ export const PO_STATUS_VARIANTS: Record<POStatus, BadgeVariant> = {
|
|||
PAID_DELIVERED: "success",
|
||||
PARTIALLY_CLOSED: "warning",
|
||||
CLOSED: "secondary",
|
||||
CANCELLED: "danger",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
-- Cancel + supersede: a new terminal CANCELLED status, cancel metadata, and a
|
||||
-- self-referential supersede link (cancelled PO -> the existing PO that replaces it).
|
||||
ALTER TYPE "POStatus" ADD VALUE 'CANCELLED';
|
||||
ALTER TYPE "ActionType" ADD VALUE 'CANCELLED';
|
||||
ALTER TYPE "ActionType" ADD VALUE 'SUPERSEDED';
|
||||
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancelledAt" TIMESTAMP(3);
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "cancellationReason" TEXT;
|
||||
ALTER TABLE "PurchaseOrder" ADD COLUMN "supersededById" TEXT;
|
||||
|
||||
ALTER TABLE "PurchaseOrder" ADD CONSTRAINT "PurchaseOrder_supersededById_fkey"
|
||||
FOREIGN KEY ("supersededById") REFERENCES "PurchaseOrder"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "RankCategory" AS ENUM ('OPERATIONAL', 'SUPPORT');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SeafarerDocType" AS ENUM ('STCW', 'AADHAAR', 'PAN', 'PASSPORT', 'CDC', 'COC', 'PHOTOGRAPH', 'DRIVING_LICENSE', 'MEDICAL_FITNESS', 'CONTRACT_LETTER');
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Role" ADD VALUE 'SITE_STAFF';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Rank" (
|
||||
"id" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"category" "RankCategory" NOT NULL DEFAULT 'OPERATIONAL',
|
||||
"isSeafarer" BOOLEAN NOT NULL DEFAULT false,
|
||||
"grantsLogin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
"parentId" TEXT,
|
||||
|
||||
CONSTRAINT "Rank_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "RankDocRequirement" (
|
||||
"id" TEXT NOT NULL,
|
||||
"rankId" TEXT NOT NULL,
|
||||
"docType" "SeafarerDocType" NOT NULL,
|
||||
"isMandatory" BOOLEAN NOT NULL DEFAULT true,
|
||||
"note" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "RankDocRequirement_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Rank_code_key" ON "Rank"("code");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "RankDocRequirement_rankId_docType_key" ON "RankDocRequirement"("rankId", "docType");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Rank" ADD CONSTRAINT "Rank_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "Rank"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "RankDocRequirement" ADD CONSTRAINT "RankDocRequirement_rankId_fkey" FOREIGN KEY ("rankId") REFERENCES "Rank"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
48
App/prisma/rank-data.ts
Normal file
48
App/prisma/rank-data.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// Crew rank hierarchy (org chart) — captured from Crewing.excalidraw and the
|
||||
// tree in wiki Crewing-Data-Model §2. A self-referential tree like the Account
|
||||
// accounting-code hierarchy: `parentCode = null` is the top of the org.
|
||||
//
|
||||
// grantsLogin = true → only PM, Assistant PM, Site In-charge (the three ranks
|
||||
// that map to a SITE_STAFF portal login). Everyone else is
|
||||
// a crew member / data subject with no account.
|
||||
// isSeafarer = true → the dredging/engine/deck crew who hold seafarer documents
|
||||
// (STCW/CDC/COC/medical). Management & shore-support do not.
|
||||
// category → OPERATIONAL vs SUPPORT (the classDef "sup" nodes).
|
||||
|
||||
import type { RankCategory } from "@prisma/client";
|
||||
|
||||
export type RankEntry = {
|
||||
code: string;
|
||||
name: string;
|
||||
parentCode: string | null;
|
||||
category: RankCategory;
|
||||
isSeafarer: boolean;
|
||||
grantsLogin: boolean;
|
||||
};
|
||||
|
||||
export const RANKS: RankEntry[] = [
|
||||
// ── Management (portal logins) ──────────────────────────────────────────────
|
||||
{ code: "PM", name: "PM", parentCode: null, category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
{ code: "APM", name: "Assistant PM", parentCode: "PM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
{ code: "SIC", name: "Site In-charge", parentCode: "APM", category: "OPERATIONAL", isSeafarer: false, grantsLogin: true },
|
||||
|
||||
// ── Shore support (no login, no seafarer docs) ──────────────────────────────
|
||||
{ code: "ACC", name: "Accountant", parentCode: "APM", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
|
||||
{ code: "DRV", name: "Driver", parentCode: "APM", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
|
||||
{ code: "COOK", name: "Cook", parentCode: "APM", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
|
||||
{ code: "CKH", name: "Cook Helper", parentCode: "COOK", category: "SUPPORT", isSeafarer: false, grantsLogin: false },
|
||||
|
||||
// ── Operational crew (seafarers) ────────────────────────────────────────────
|
||||
{ code: "DIC", name: "Dredger In-charge", parentCode: "SIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "SDO", name: "Sr. Dredge Operator", parentCode: "DIC", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "PLS", name: "Pipeline Supervisor", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "PLA", name: "Pipeline Assistant", parentCode: "PLS", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "JDO", name: "Jr. Dredge Operator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "ERO", name: "Engine Room Operator", parentCode: "JDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "DH", name: "Deck Hand", parentCode: "ERO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "TR", name: "Trainee", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "MB", name: "Mess Boy", parentCode: "DH", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "ELE", name: "Electrician", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "SFB", name: "Sr. Fabricator", parentCode: "SDO", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
{ code: "FW", name: "Fabricator / Welder", parentCode: "SFB", category: "OPERATIONAL", isSeafarer: true, grantsLogin: false },
|
||||
];
|
||||
42
App/prisma/rank-doc-data.ts
Normal file
42
App/prisma/rank-doc-data.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Default required-document set per rank, derived from the rank flags in
|
||||
// rank-data.ts. Drives candidate vetting and crew uploads (RankDocRequirement).
|
||||
// - Every crew member: Aadhaar, PAN, photograph.
|
||||
// - Seafarers additionally: STCW, CDC, passport, medical fitness (mandatory);
|
||||
// COC is conditional (officer/senior ranks only).
|
||||
// - Driver: driving licence (mandatory).
|
||||
// Editable afterwards at /admin/ranks; this is just the seeded baseline.
|
||||
|
||||
import type { SeafarerDocType } from "@prisma/client";
|
||||
import { RANKS } from "./rank-data";
|
||||
|
||||
export type RankDocReq = {
|
||||
rankCode: string;
|
||||
docType: SeafarerDocType;
|
||||
isMandatory: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
const COMMON: { docType: SeafarerDocType; isMandatory: boolean }[] = [
|
||||
{ docType: "AADHAAR", isMandatory: true },
|
||||
{ docType: "PAN", isMandatory: true },
|
||||
{ docType: "PHOTOGRAPH", isMandatory: true },
|
||||
];
|
||||
|
||||
const SEAFARER: { docType: SeafarerDocType; isMandatory: boolean; note?: string }[] = [
|
||||
{ docType: "STCW", isMandatory: true },
|
||||
{ docType: "CDC", isMandatory: true },
|
||||
{ docType: "PASSPORT", isMandatory: true },
|
||||
{ docType: "MEDICAL_FITNESS", isMandatory: true },
|
||||
{ docType: "COC", isMandatory: false, note: "Officer / senior ranks only" },
|
||||
];
|
||||
|
||||
export const RANK_DOC_REQUIREMENTS: RankDocReq[] = RANKS.flatMap((rank) => {
|
||||
const reqs: RankDocReq[] = COMMON.map((c) => ({ rankCode: rank.code, ...c }));
|
||||
if (rank.isSeafarer) {
|
||||
reqs.push(...SEAFARER.map((s) => ({ rankCode: rank.code, ...s })));
|
||||
}
|
||||
if (rank.code === "DRV") {
|
||||
reqs.push({ rankCode: "DRV", docType: "DRIVING_LICENSE", isMandatory: true });
|
||||
}
|
||||
return reqs;
|
||||
});
|
||||
|
|
@ -15,6 +15,7 @@ enum Role {
|
|||
SUPERUSER
|
||||
AUDITOR
|
||||
ADMIN
|
||||
SITE_STAFF
|
||||
}
|
||||
|
||||
enum POStatus {
|
||||
|
|
@ -30,6 +31,7 @@ enum POStatus {
|
|||
PAID_DELIVERED
|
||||
PARTIALLY_CLOSED
|
||||
CLOSED
|
||||
CANCELLED
|
||||
}
|
||||
|
||||
enum ActionType {
|
||||
|
|
@ -49,6 +51,8 @@ enum ActionType {
|
|||
REASSIGNED
|
||||
PRODUCT_PRICE_UPDATED
|
||||
MANAGER_LINE_EDIT
|
||||
CANCELLED
|
||||
SUPERSEDED
|
||||
}
|
||||
|
||||
enum RequestStatus {
|
||||
|
|
@ -57,6 +61,32 @@ enum RequestStatus {
|
|||
DENIED
|
||||
}
|
||||
|
||||
// ─── Crewing (feature-flagged: NEXT_PUBLIC_CREWING_ENABLED) ──────────────────
|
||||
// Phase 1 (Foundations) lands only the reference-data layer. The lifecycle
|
||||
// models/enums (Requisition, Application, Assignment, …) arrive in later phases.
|
||||
// See wiki Crewing-Implementation-Spec §12.
|
||||
|
||||
// Org-chart grouping for a Rank. Drives reporting/segmentation, not login.
|
||||
enum RankCategory {
|
||||
OPERATIONAL
|
||||
SUPPORT
|
||||
}
|
||||
|
||||
// The seafarer/crew document set a rank may be required to hold. Drives
|
||||
// candidate vetting and crew uploads via RankDocRequirement.
|
||||
enum SeafarerDocType {
|
||||
STCW
|
||||
AADHAAR
|
||||
PAN
|
||||
PASSPORT
|
||||
CDC
|
||||
COC
|
||||
PHOTOGRAPH
|
||||
DRIVING_LICENSE
|
||||
MEDICAL_FITNESS
|
||||
CONTRACT_LETTER
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
employeeId String @unique
|
||||
|
|
@ -270,6 +300,8 @@ model PurchaseOrder {
|
|||
approvedAt DateTime?
|
||||
paidAt DateTime?
|
||||
closedAt DateTime?
|
||||
cancelledAt DateTime?
|
||||
cancellationReason String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
|
@ -286,6 +318,12 @@ model PurchaseOrder {
|
|||
siteId String?
|
||||
site Site? @relation(fields: [siteId], references: [id])
|
||||
|
||||
// Supersede: a cancelled PO may be linked to the existing PO that replaces it.
|
||||
// `supersededBy` is that replacement; `supersedes` is the reciprocal list.
|
||||
supersededById String?
|
||||
supersededBy PurchaseOrder? @relation("Supersede", fields: [supersededById], references: [id])
|
||||
supersedes PurchaseOrder[] @relation("Supersede")
|
||||
|
||||
lineItems POLineItem[]
|
||||
documents PODocument[]
|
||||
actions POAction[]
|
||||
|
|
@ -364,3 +402,43 @@ model Notification {
|
|||
userId String
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
}
|
||||
|
||||
// ─── Crewing reference data ──────────────────────────────────────────────────
|
||||
|
||||
// The crew org hierarchy. A self-referential tree (parent/children), exactly
|
||||
// like the Account accounting-code hierarchy. Reference data managed at
|
||||
// /admin/ranks. `grantsLogin` is true only for the three management ranks
|
||||
// (PM, Assistant PM, Site In-charge) — every other rank is a crew member /
|
||||
// data subject with no portal account. See Crewing-Data-Model §2.
|
||||
model Rank {
|
||||
id String @id @default(cuid())
|
||||
code String @unique
|
||||
name String
|
||||
description String?
|
||||
category RankCategory @default(OPERATIONAL)
|
||||
isSeafarer Boolean @default(false)
|
||||
grantsLogin Boolean @default(false)
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
parentId String?
|
||||
parent Rank? @relation("RankHierarchy", fields: [parentId], references: [id])
|
||||
children Rank[] @relation("RankHierarchy")
|
||||
|
||||
docRequirements RankDocRequirement[]
|
||||
}
|
||||
|
||||
// Which documents a rank is required (or conditionally required) to hold.
|
||||
// `isMandatory = false` is the "conditional" tag in the UI.
|
||||
model RankDocRequirement {
|
||||
id String @id @default(cuid())
|
||||
rankId String
|
||||
rank Rank @relation(fields: [rankId], references: [id], onDelete: Cascade)
|
||||
docType SeafarerDocType
|
||||
isMandatory Boolean @default(true)
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([rankId, docType])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
import { PrismaClient, Role } from "@prisma/client";
|
||||
import { ACCOUNTING_CODES } from "./accounting-codes-data";
|
||||
import { seedRanks } from "./seed-ranks";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const hash = (p: string) => bcrypt.hash(p, 12);
|
||||
|
|
@ -235,6 +236,10 @@ async function main() {
|
|||
}).length;
|
||||
console.log(` ✓ ${ACCOUNTING_CODES.length} codes (${leafCount} selectable leaf items)`);
|
||||
|
||||
// ── Crewing reference data (ranks + document rules) ──────────────────────────
|
||||
console.log("\n⚓ Seeding crew ranks…");
|
||||
await seedRanks(db);
|
||||
|
||||
console.log("\n✅ Production seed complete.");
|
||||
}
|
||||
|
||||
|
|
|
|||
56
App/prisma/seed-ranks.ts
Normal file
56
App/prisma/seed-ranks.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// Shared, idempotent seeding of the crewing reference data (ranks + their
|
||||
// required-document rules). Used by both the dev seed (seed.ts) and the
|
||||
// production seed (seed-prod.ts). Two passes mirror the accounting-code seed:
|
||||
// upsert every rank by code, then link parents, then upsert doc requirements.
|
||||
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { RANKS } from "./rank-data";
|
||||
import { RANK_DOC_REQUIREMENTS } from "./rank-doc-data";
|
||||
|
||||
export async function seedRanks(db: PrismaClient) {
|
||||
const rankIdMap = new Map<string, string>();
|
||||
|
||||
// Pass 1: upsert all ranks (no parent yet) to obtain ids.
|
||||
for (const r of RANKS) {
|
||||
const rec = await db.rank.upsert({
|
||||
where: { code: r.code },
|
||||
update: {
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
isSeafarer: r.isSeafarer,
|
||||
grantsLogin: r.grantsLogin,
|
||||
},
|
||||
create: {
|
||||
code: r.code,
|
||||
name: r.name,
|
||||
category: r.category,
|
||||
isSeafarer: r.isSeafarer,
|
||||
grantsLogin: r.grantsLogin,
|
||||
},
|
||||
});
|
||||
rankIdMap.set(r.code, rec.id);
|
||||
}
|
||||
|
||||
// Pass 2: link parent relationships.
|
||||
for (const r of RANKS) {
|
||||
if (r.parentCode) {
|
||||
const parentId = rankIdMap.get(r.parentCode);
|
||||
if (parentId) {
|
||||
await db.rank.update({ where: { code: r.code }, data: { parentId } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Document requirements (keyed by the rank + docType compound unique).
|
||||
for (const req of RANK_DOC_REQUIREMENTS) {
|
||||
const rankId = rankIdMap.get(req.rankCode);
|
||||
if (!rankId) continue;
|
||||
await db.rankDocRequirement.upsert({
|
||||
where: { rankId_docType: { rankId, docType: req.docType } },
|
||||
update: { isMandatory: req.isMandatory, note: req.note ?? null },
|
||||
create: { rankId, docType: req.docType, isMandatory: req.isMandatory, note: req.note ?? null },
|
||||
});
|
||||
}
|
||||
|
||||
console.log(` ✓ ${RANKS.length} ranks, ${RANK_DOC_REQUIREMENTS.length} document requirements`);
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { PrismaClient, Role } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { ACCOUNTING_CODES } from "./accounting-codes-data";
|
||||
import { seedRanks } from "./seed-ranks";
|
||||
|
||||
const db = new PrismaClient();
|
||||
|
||||
|
|
@ -203,6 +204,9 @@ async function main() {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Crewing: Ranks (hierarchical) + document requirements ───────────────────
|
||||
await seedRanks(db);
|
||||
|
||||
// Convenience variables for PO seed data below (map to real leaf codes)
|
||||
const accTechOps = { id: codeIdMap.get("401012")! }; // Spares- Others
|
||||
const accCrewMgt = { id: codeIdMap.get("500101")! }; // Salary
|
||||
|
|
|
|||
BIN
App/tests/fixtures/Sample_PO.xlsx
vendored
Normal file
BIN
App/tests/fixtures/Sample_PO.xlsx
vendored
Normal file
Binary file not shown.
145
App/tests/integration/admin-ranks.test.ts
Normal file
145
App/tests/integration/admin-ranks.test.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* Integration tests for the Ranks & Documents admin server actions (Crewing
|
||||
* Phase 1 foundations). Covers create/update/delete, parent linking, the
|
||||
* cycle/children guards, doc-requirement add/remove, and permission gating.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
// The actions are gated by the crewing flag; force it on for the test run.
|
||||
vi.mock("@/lib/feature-flags", () => ({ CREWING_ENABLED: true, INVENTORY_ENABLED: true }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
createRank,
|
||||
updateRank,
|
||||
deleteRank,
|
||||
addRankDocRequirement,
|
||||
removeRankDocRequirement,
|
||||
} from "@/app/(portal)/admin/ranks/actions";
|
||||
import { makeSession, getSeedUser, fd } from "./helpers";
|
||||
|
||||
const PREFIX = "ITRANK_";
|
||||
let managerId: string;
|
||||
|
||||
const asManager = () =>
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
|
||||
beforeAll(async () => {
|
||||
const mgr = await getSeedUser("manager@pelagia.local");
|
||||
managerId = mgr.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Break self-relations before deleting so parent/child FK order never bites.
|
||||
const rows = await db.rank.findMany({ where: { code: { startsWith: PREFIX } }, select: { id: true } });
|
||||
const ids = rows.map((r) => r.id);
|
||||
if (ids.length) {
|
||||
await db.rankDocRequirement.deleteMany({ where: { rankId: { in: ids } } });
|
||||
await db.rank.updateMany({ where: { id: { in: ids } }, data: { parentId: null } });
|
||||
await db.rank.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("createRank", () => {
|
||||
it("creates a rank with its flags", async () => {
|
||||
asManager();
|
||||
const res = await createRank(
|
||||
fd({ code: `${PREFIX}PM`, name: "Test PM", category: "OPERATIONAL", grantsLogin: "on" })
|
||||
);
|
||||
expect(res).toEqual({ ok: true });
|
||||
|
||||
const rank = await db.rank.findUnique({ where: { code: `${PREFIX}PM` } });
|
||||
expect(rank?.grantsLogin).toBe(true);
|
||||
expect(rank?.isSeafarer).toBe(false);
|
||||
expect(rank?.category).toBe("OPERATIONAL");
|
||||
});
|
||||
|
||||
it("rejects a duplicate code", async () => {
|
||||
asManager();
|
||||
await createRank(fd({ code: `${PREFIX}DUP`, name: "One", category: "SUPPORT" }));
|
||||
const res = await createRank(fd({ code: `${PREFIX}DUP`, name: "Two", category: "SUPPORT" }));
|
||||
expect("error" in res).toBe(true);
|
||||
});
|
||||
|
||||
it("is rejected for roles without manage_ranks", async () => {
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "SITE_STAFF"));
|
||||
const res = await createRank(fd({ code: `${PREFIX}NO`, name: "Nope", category: "OPERATIONAL" }));
|
||||
expect(res).toEqual({ error: "Unauthorized" });
|
||||
expect(await db.rank.findUnique({ where: { code: `${PREFIX}NO` } })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateRank — parent linking & cycle guard", () => {
|
||||
it("links a child to its parent", async () => {
|
||||
asManager();
|
||||
await createRank(fd({ code: `${PREFIX}P`, name: "Parent", category: "OPERATIONAL" }));
|
||||
const parent = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}P` } });
|
||||
await createRank(fd({ code: `${PREFIX}C`, name: "Child", category: "OPERATIONAL" }));
|
||||
const child = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}C` } });
|
||||
|
||||
const res = await updateRank(
|
||||
fd({ id: child.id, code: `${PREFIX}C`, name: "Child", category: "OPERATIONAL", parentId: parent.id })
|
||||
);
|
||||
expect(res).toEqual({ ok: true });
|
||||
expect((await db.rank.findUnique({ where: { id: child.id } }))?.parentId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it("refuses to make a rank its own ancestor", async () => {
|
||||
asManager();
|
||||
await createRank(fd({ code: `${PREFIX}A`, name: "A", category: "OPERATIONAL" }));
|
||||
const a = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}A` } });
|
||||
await createRank(fd({ code: `${PREFIX}B`, name: "B", category: "OPERATIONAL", parentId: a.id }));
|
||||
const b = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}B` } });
|
||||
|
||||
// Try to make A report to B (its own descendant) → cycle.
|
||||
const res = await updateRank(fd({ id: a.id, code: `${PREFIX}A`, name: "A", category: "OPERATIONAL", parentId: b.id }));
|
||||
expect("error" in res).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteRank", () => {
|
||||
it("blocks deletion when the rank has sub-ranks", async () => {
|
||||
asManager();
|
||||
await createRank(fd({ code: `${PREFIX}TOP`, name: "Top", category: "OPERATIONAL" }));
|
||||
const top = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}TOP` } });
|
||||
await createRank(fd({ code: `${PREFIX}SUB`, name: "Sub", category: "OPERATIONAL", parentId: top.id }));
|
||||
|
||||
const res = await deleteRank(top.id);
|
||||
expect("error" in res).toBe(true);
|
||||
expect(await db.rank.findUnique({ where: { id: top.id } })).not.toBeNull();
|
||||
});
|
||||
|
||||
it("deletes a leaf rank and cascades its doc requirements", async () => {
|
||||
asManager();
|
||||
await createRank(fd({ code: `${PREFIX}LEAF`, name: "Leaf", category: "OPERATIONAL" }));
|
||||
const leaf = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}LEAF` } });
|
||||
await addRankDocRequirement(fd({ rankId: leaf.id, docType: "PASSPORT", isMandatory: "on" }));
|
||||
|
||||
const res = await deleteRank(leaf.id);
|
||||
expect(res).toEqual({ ok: true });
|
||||
expect(await db.rankDocRequirement.findMany({ where: { rankId: leaf.id } })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rank document requirements", () => {
|
||||
it("adds (upserting) and removes a requirement", async () => {
|
||||
asManager();
|
||||
await createRank(fd({ code: `${PREFIX}DOC`, name: "Doc", category: "OPERATIONAL", isSeafarer: "on" }));
|
||||
const rank = await db.rank.findUniqueOrThrow({ where: { code: `${PREFIX}DOC` } });
|
||||
|
||||
await addRankDocRequirement(fd({ rankId: rank.id, docType: "STCW", isMandatory: "on" }));
|
||||
// Upsert: same docType again flips it to conditional rather than duplicating.
|
||||
await addRankDocRequirement(fd({ rankId: rank.id, docType: "STCW", isMandatory: "false" }));
|
||||
const reqs = await db.rankDocRequirement.findMany({ where: { rankId: rank.id } });
|
||||
expect(reqs).toHaveLength(1);
|
||||
expect(reqs[0].isMandatory).toBe(false);
|
||||
|
||||
const rm = await removeRankDocRequirement(reqs[0].id);
|
||||
expect(rm).toEqual({ ok: true });
|
||||
expect(await db.rankDocRequirement.findMany({ where: { rankId: rank.id } })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -32,7 +32,7 @@ beforeAll(async () => {
|
|||
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedVessel("MV Ocean Pride"),
|
||||
getSeedVessel("MV Poseidon"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
|
|
@ -52,7 +52,11 @@ async function createSubmittedPo(title: string): Promise<string> {
|
|||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||
const result = await createPo(form);
|
||||
return (result as { id: string }).id;
|
||||
const id = (result as { id: string }).id;
|
||||
// Vendor gating: a vendor must be assigned before a PO can be approved.
|
||||
// Attach the seeded verified vendor directly (test setup) so approval-path tests run.
|
||||
await db.purchaseOrder.update({ where: { id }, data: { vendorId } });
|
||||
return id;
|
||||
}
|
||||
|
||||
// ── M-02: Approve ─────────────────────────────────────────────────────────────
|
||||
|
|
@ -340,7 +344,7 @@ describe("S-07 — edit and resubmit after edits requested", () => {
|
|||
await requestEdits({ poId, note: "Update line items" });
|
||||
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "submit" });
|
||||
const form = makePoForm({ title: `${PREFIX}Resubmit`, vesselId, accountId, intent: "resubmit" });
|
||||
const result = await updatePo(poId, form);
|
||||
expect(result).toEqual({ id: poId });
|
||||
|
||||
|
|
|
|||
181
App/tests/integration/cancel-supersede.test.ts
Normal file
181
App/tests/integration/cancel-supersede.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Integration tests for PO cancellation and supersede linkage.
|
||||
* Covers: cancel from any state (MANAGER/SUPERUSER, reason required), exclusion
|
||||
* from spend aggregation, and linking a cancelled PO to an existing replacement.
|
||||
*
|
||||
* POs are built directly via db.create (not the makePoForm helper) so the test is
|
||||
* self-contained and cleans up cascade-safely (POAction has no onDelete: Cascade).
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { cancelPo, supersedePo } from "@/app/(portal)/po/[id]/actions";
|
||||
import { POST_APPROVAL_STATUSES } from "@/lib/utils";
|
||||
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor } from "./helpers";
|
||||
import type { POStatus } from "@prisma/client";
|
||||
|
||||
const mockedAuth = vi.mocked(auth);
|
||||
const PREFIX = "INTTEST_CANCEL_";
|
||||
let techId: string;
|
||||
let managerId: string;
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
let vendorId: string;
|
||||
let seq = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [tech, mgr, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedVessel("MV Galatea"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id; managerId = mgr.id;
|
||||
vesselId = vessel.id; accountId = account.id; vendorId = vendor.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const pos = await db.purchaseOrder.findMany({ where: { title: { startsWith: PREFIX } }, select: { id: true } });
|
||||
const ids = pos.map((p) => p.id);
|
||||
if (ids.length === 0) return;
|
||||
await db.purchaseOrder.updateMany({ where: { id: { in: ids } }, data: { supersededById: null } });
|
||||
await db.pOAction.deleteMany({ where: { poId: { in: ids } } });
|
||||
await db.purchaseOrder.deleteMany({ where: { id: { in: ids } } });
|
||||
});
|
||||
|
||||
async function makePo(label: string, status: POStatus): Promise<string> {
|
||||
seq += 1;
|
||||
const po = await db.purchaseOrder.create({
|
||||
data: {
|
||||
poNumber: `CANCELTEST-${seq}-${label}`,
|
||||
title: `${PREFIX}${label}`,
|
||||
status,
|
||||
totalAmount: 1180,
|
||||
currency: "INR",
|
||||
vesselId,
|
||||
accountId,
|
||||
submitterId: techId,
|
||||
...(status === "MGR_APPROVED" ? { vendorId, approvedAt: new Date() } : {}),
|
||||
lineItems: { create: [{ name: "Test Item", quantity: 10, unit: "pc", unitPrice: 100, totalPrice: 1180, gstRate: 0.18, sortOrder: 0 }] },
|
||||
actions: { create: { actionType: "CREATED", actorId: techId } },
|
||||
},
|
||||
});
|
||||
return po.id;
|
||||
}
|
||||
|
||||
describe("cancelPo", () => {
|
||||
it("cancels a DRAFT PO with a reason and writes an audit row", async () => {
|
||||
const poId = await makePo("Draft", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
|
||||
const result = await cancelPo({ poId, reason: "Duplicate order" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
expect(po.status).toBe("CANCELLED");
|
||||
expect(po.cancelledAt).not.toBeNull();
|
||||
expect(po.cancellationReason).toBe("Duplicate order");
|
||||
|
||||
const action = await db.pOAction.findFirst({ where: { poId, actionType: "CANCELLED" } });
|
||||
expect(action?.note).toBe("Duplicate order");
|
||||
});
|
||||
|
||||
it("cancels an already-APPROVED PO (cancellable from any state)", async () => {
|
||||
const poId = await makePo("Approved", "MGR_APPROVED");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
const result = await cancelPo({ poId, reason: "Vendor backed out" });
|
||||
expect(result).toEqual({ ok: true });
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
expect(po.status).toBe("CANCELLED");
|
||||
});
|
||||
|
||||
it("a cancelled PO drops out of the spend aggregation filter", async () => {
|
||||
const poId = await makePo("Spend", "MGR_APPROVED");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "Excluded from spend" });
|
||||
|
||||
expect(POST_APPROVAL_STATUSES as readonly string[]).not.toContain("CANCELLED");
|
||||
const stillCounted = await db.purchaseOrder.findFirst({
|
||||
where: { id: poId, status: { in: [...POST_APPROVAL_STATUSES] } },
|
||||
});
|
||||
expect(stillCounted).toBeNull();
|
||||
});
|
||||
|
||||
it("requires a reason", async () => {
|
||||
const poId = await makePo("NoReason", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
const result = await cancelPo({ poId, reason: " " });
|
||||
expect(result).toEqual({ error: "A cancellation reason is required." });
|
||||
});
|
||||
|
||||
it("refuses a role without cancel_po (TECHNICAL)", async () => {
|
||||
const poId = await makePo("Forbidden", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(techId, "TECHNICAL") as never);
|
||||
const result = await cancelPo({ poId, reason: "nope" });
|
||||
expect(result).toHaveProperty("error");
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
expect(po.status).toBe("DRAFT");
|
||||
});
|
||||
|
||||
it("refuses to cancel an already-cancelled PO", async () => {
|
||||
const poId = await makePo("Twice", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "first" });
|
||||
const result = await cancelPo({ poId, reason: "second" });
|
||||
expect(result).toEqual({ error: "This purchase order is already cancelled." });
|
||||
});
|
||||
});
|
||||
|
||||
describe("supersedePo", () => {
|
||||
it("links a cancelled PO to an existing replacement (reciprocal)", async () => {
|
||||
const cancelledId = await makePo("Old", "DRAFT");
|
||||
const replacementId = await makePo("New", "DRAFT");
|
||||
const replacement = await db.purchaseOrder.findUniqueOrThrow({ where: { id: replacementId } });
|
||||
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId: cancelledId, reason: "Replaced" });
|
||||
const result = await supersedePo({ poId: cancelledId, replacementPoNumber: replacement.poNumber });
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const old = await db.purchaseOrder.findUniqueOrThrow({ where: { id: cancelledId } });
|
||||
expect(old.supersededById).toBe(replacementId);
|
||||
|
||||
const repl = await db.purchaseOrder.findUniqueOrThrow({
|
||||
where: { id: replacementId },
|
||||
include: { supersedes: { select: { id: true } } },
|
||||
});
|
||||
expect(repl.supersedes.map((s) => s.id)).toContain(cancelledId);
|
||||
});
|
||||
|
||||
it("refuses to supersede a PO that is not cancelled", async () => {
|
||||
const poId = await makePo("NotCancelled", "DRAFT");
|
||||
const otherId = await makePo("Other", "DRAFT");
|
||||
const other = await db.purchaseOrder.findUniqueOrThrow({ where: { id: otherId } });
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
const result = await supersedePo({ poId, replacementPoNumber: other.poNumber });
|
||||
expect(result).toEqual({ error: "Only a cancelled purchase order can be superseded." });
|
||||
});
|
||||
|
||||
it("rejects an unknown replacement PO number", async () => {
|
||||
const poId = await makePo("Unknown", "DRAFT");
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "x" });
|
||||
const result = await supersedePo({ poId, replacementPoNumber: "PMS/ZZZ/0000/2000-01" });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("rejects self-supersede", async () => {
|
||||
const poId = await makePo("Self", "DRAFT");
|
||||
const po = await db.purchaseOrder.findUniqueOrThrow({ where: { id: poId } });
|
||||
mockedAuth.mockResolvedValue(makeSession(managerId, "MANAGER") as never);
|
||||
await cancelPo({ poId, reason: "x" });
|
||||
const result = await supersedePo({ poId, replacementPoNumber: po.poNumber });
|
||||
expect(result).toEqual({ error: "A purchase order cannot supersede itself." });
|
||||
});
|
||||
});
|
||||
84
App/tests/integration/company-crud.test.ts
Normal file
84
App/tests/integration/company-crud.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Integration tests for company create/update actions.
|
||||
* Focus on the behaviour the dedicated add/edit pages rely on:
|
||||
* - createCompany returns the new id (so the create flow can redirect to the edit page)
|
||||
* - fields persist, code is upper-cased, duplicate codes are rejected
|
||||
* - updateCompany edits in place
|
||||
* - both actions are gated by manage_vessels_accounts
|
||||
*/
|
||||
import { vi, describe, it, expect, afterAll } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { createCompany, updateCompany } from "@/app/(portal)/admin/companies/actions";
|
||||
import { makeSession, fd } from "./helpers";
|
||||
|
||||
const mockedAuth = vi.mocked(auth);
|
||||
const NAME_PREFIX = "INTTEST_CRUD_";
|
||||
|
||||
afterAll(async () => {
|
||||
await db.company.deleteMany({ where: { name: { startsWith: NAME_PREFIX } } });
|
||||
});
|
||||
|
||||
describe("createCompany", () => {
|
||||
it("returns the new id and persists the company (code upper-cased)", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const result = await createCompany(fd({
|
||||
name: `${NAME_PREFIX}Alpha`,
|
||||
code: "zzcrudA",
|
||||
gstNumber: "27AAHCP5787B1Z6",
|
||||
}));
|
||||
|
||||
expect("id" in result && result.ok).toBe(true);
|
||||
if (!("id" in result)) throw new Error(result.error);
|
||||
|
||||
const c = await db.company.findUniqueOrThrow({ where: { id: result.id } });
|
||||
expect(c.name).toBe(`${NAME_PREFIX}Alpha`);
|
||||
expect(c.code).toBe("ZZCRUDA");
|
||||
expect(c.gstNumber).toBe("27AAHCP5787B1Z6");
|
||||
});
|
||||
|
||||
it("rejects a duplicate code (case-insensitive)", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const first = await createCompany(fd({ name: `${NAME_PREFIX}Dup1`, code: "zzcrudd" }));
|
||||
expect("id" in first).toBe(true);
|
||||
|
||||
const second = await createCompany(fd({ name: `${NAME_PREFIX}Dup2`, code: "ZZCRUDD" }));
|
||||
expect("error" in second).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses callers without manage_vessels_accounts", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
const result = await createCompany(fd({ name: `${NAME_PREFIX}Nope`, code: "zzcrudN" }));
|
||||
expect(result).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateCompany", () => {
|
||||
it("edits an existing company in place", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
||||
const created = await createCompany(fd({ name: `${NAME_PREFIX}Edit`, code: "zzcrudE" }));
|
||||
if (!("id" in created)) throw new Error(created.error);
|
||||
|
||||
const result = await updateCompany(fd({
|
||||
id: created.id,
|
||||
name: `${NAME_PREFIX}Edited`,
|
||||
code: "zzcrudE",
|
||||
mobile: "+91 99999 00000",
|
||||
}));
|
||||
expect(result).toEqual({ ok: true });
|
||||
|
||||
const c = await db.company.findUniqueOrThrow({ where: { id: created.id } });
|
||||
expect(c.name).toBe(`${NAME_PREFIX}Edited`);
|
||||
expect(c.mobile).toBe("+91 99999 00000");
|
||||
});
|
||||
|
||||
it("refuses callers without manage_vessels_accounts", async () => {
|
||||
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
||||
const result = await updateCompany(fd({ id: "whatever", name: "x", code: "ZZX" }));
|
||||
expect(result).toEqual({ error: "Unauthorized" });
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
getSeedUser,
|
||||
getSeedVessel,
|
||||
getSeedAccount,
|
||||
getSeedVendor,
|
||||
makePoForm,
|
||||
deletePosByTitle,
|
||||
} from "./helpers";
|
||||
|
|
@ -32,20 +33,23 @@ let managerId: string;
|
|||
let accountsId: string;
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
let vendorId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
||||
const [tech, mgr, acct, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Sea Breeze"),
|
||||
getSeedVessel("MV Nereid"),
|
||||
getSeedAccount("700202"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
managerId = mgr.id;
|
||||
accountsId = acct.id;
|
||||
vesselId = vessel.id;
|
||||
accountId = account.id;
|
||||
vendorId = vendor.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -57,6 +61,8 @@ async function createPaidPo(title: string): Promise<string> {
|
|||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
// Vendor gating: approval requires an assigned vendor.
|
||||
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
|
||||
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await approvePo({ poId });
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ let vendorId: string;
|
|||
beforeAll(async () => {
|
||||
const [tech, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedVessel("MV Ocean Pride"),
|
||||
getSeedVessel("MV Aegean Wind"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
|
|
@ -79,7 +79,7 @@ describe("S-02 — save as draft", () => {
|
|||
form.set("title", `${PREFIX}NoVessel`);
|
||||
form.set("accountId", accountId);
|
||||
form.set("intent", "draft");
|
||||
form.set("lineItems[0].description", "Item");
|
||||
form.set("lineItems[0].name", "Item");
|
||||
form.set("lineItems[0].quantity", "1");
|
||||
form.set("lineItems[0].unit", "pc");
|
||||
form.set("lineItems[0].unitPrice", "50");
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ beforeAll(async () => {
|
|||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Pelagia Star"),
|
||||
getSeedAccount("TECH-OPS"),
|
||||
getSeedAccount("700201"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
managerId = mgr.id;
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function appendLineItem(
|
|||
idx: number,
|
||||
item: { description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }
|
||||
) {
|
||||
form.set(`lineItems[${idx}].description`, item.description);
|
||||
form.set(`lineItems[${idx}].name`, item.description);
|
||||
form.set(`lineItems[${idx}].quantity`, String(item.quantity));
|
||||
form.set(`lineItems[${idx}].unit`, item.unit);
|
||||
form.set(`lineItems[${idx}].unitPrice`, String(item.unitPrice));
|
||||
|
|
@ -58,7 +58,7 @@ export function makePoForm(overrides: {
|
|||
vesselId: string;
|
||||
accountId: string;
|
||||
vendorId?: string;
|
||||
intent?: "draft" | "submit";
|
||||
intent?: "draft" | "submit" | "resubmit";
|
||||
lineItems?: Array<{ description: string; quantity: number; unit: string; unitPrice: number; gstRate?: number }>;
|
||||
}): FormData {
|
||||
const form = new FormData();
|
||||
|
|
@ -76,12 +76,23 @@ export function makePoForm(overrides: {
|
|||
|
||||
// ── Cleanup helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
// POAction and Receipt have no onDelete: Cascade, so their rows must be removed
|
||||
// before the PO. (POLineItem / PODocument cascade automatically.)
|
||||
async function deletePosByIds(ids: string[]) {
|
||||
if (ids.length === 0) return;
|
||||
await db.pOAction.deleteMany({ where: { poId: { in: ids } } });
|
||||
await db.receipt.deleteMany({ where: { poId: { in: ids } } });
|
||||
await db.purchaseOrder.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
export async function deletePo(poId: string) {
|
||||
await db.purchaseOrder.delete({ where: { id: poId } }).catch(() => {});
|
||||
await deletePosByIds([poId]).catch(() => {});
|
||||
}
|
||||
|
||||
export async function deletePosByTitle(titlePrefix: string) {
|
||||
await db.purchaseOrder.deleteMany({
|
||||
const pos = await db.purchaseOrder.findMany({
|
||||
where: { title: { startsWith: titlePrefix } },
|
||||
select: { id: true },
|
||||
});
|
||||
await deletePosByIds(pos.map((p) => p.id));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { POST } from "@/app/api/po/import/route";
|
|||
import { makeSession, getSeedUser } from "./helpers";
|
||||
import type { ParsedImport } from "@/lib/po-import-parser";
|
||||
|
||||
const SAMPLE_XLSX = resolve(__dirname, "../../../../Prototype/Sample_PO.xlsx");
|
||||
const SAMPLE_XLSX = resolve(__dirname, "../fixtures/Sample_PO.xlsx");
|
||||
|
||||
let techId: string;
|
||||
let managerId: string;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ beforeAll(async () => {
|
|||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Pelagia Star"),
|
||||
getSeedAccount("TECH-OPS"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
managerId = mgr.id;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { createPo } from "@/app/(portal)/po/new/actions";
|
|||
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
|
||||
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
|
||||
import {
|
||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount,
|
||||
makeSession, getSeedUser, getSeedVessel, getSeedAccount, getSeedVendor,
|
||||
makePoForm, deletePosByTitle,
|
||||
} from "./helpers";
|
||||
|
||||
|
|
@ -25,20 +25,23 @@ let managerId: string;
|
|||
let accountsId: string;
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
let vendorId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
||||
const [tech, mgr, acct, vessel, account, vendor] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Sea Breeze"),
|
||||
getSeedVessel("MV Thetis"),
|
||||
getSeedAccount("700202"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
managerId = mgr.id;
|
||||
accountsId = acct.id;
|
||||
vesselId = vessel.id;
|
||||
accountId = account.id;
|
||||
vendorId = vendor.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -50,6 +53,8 @@ async function createApprovedPo(title: string): Promise<string> {
|
|||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
// Vendor gating: approval requires an assigned vendor.
|
||||
await db.purchaseOrder.update({ where: { id: poId }, data: { vendorId } });
|
||||
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await approvePo({ poId });
|
||||
|
|
@ -146,14 +151,14 @@ describe("A-02 — mark PO as paid with reference number", () => {
|
|||
expect(calls).toContain("PAYMENT_SENT");
|
||||
});
|
||||
|
||||
it("MANAGER role cannot mark as paid (wrong permission)", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidMgrForbidden`);
|
||||
it("TECHNICAL role cannot mark as paid (no process_payment permission)", async () => {
|
||||
const poId = await createApprovedPo(`${PREFIX}PaidTechForbidden`);
|
||||
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
const result = await markPaid({ poId, paymentRef: "MGR-REF", paymentDate: TODAY });
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const result = await markPaid({ poId, paymentRef: "TECH-REF", paymentDate: TODAY });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ describe("GET /api/products/search — search behaviour", () => {
|
|||
it("finds products by product code", async () => {
|
||||
const res = await GET(makeRequest("LUBE"));
|
||||
const data: { code: string }[] = await res.json();
|
||||
expect(data.every((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
|
||||
// search spans code/name/description, so assert the code matches are present (not that every hit is a code match)
|
||||
expect(data.some((p) => p.code.toUpperCase().includes("LUBE"))).toBe(true);
|
||||
});
|
||||
|
||||
it("finds products by description text", async () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
* - Unverified vendor rejected by provideVendorId
|
||||
* - AUDITOR cannot provide vendor ID
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
import { vi, describe, it, expect, beforeAll, afterAll, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
|
|
@ -39,7 +39,7 @@ beforeAll(async () => {
|
|||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Pelagia Star"),
|
||||
getSeedAccount("TECH-OPS"),
|
||||
getSeedAccount("700201"),
|
||||
getSeedVendor("Apar Industries Ltd"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
|
|
@ -66,15 +66,22 @@ beforeAll(async () => {
|
|||
auditorId = created.id;
|
||||
}
|
||||
|
||||
// Grab an unverified vendor
|
||||
const unverified = await db.vendor.findFirst({ where: { isVerified: false } });
|
||||
unverifiedVendorDbId = unverified!.id;
|
||||
// A vendor with no formal vendorId code — provideVendorId must reject it.
|
||||
// (Seeded "unverified" vendors can still carry a code, so create a code-less one.)
|
||||
const noCode = await db.vendor.create({
|
||||
data: { name: `${PREFIX}NoCodeVendor`, isVerified: false, vendorId: null },
|
||||
});
|
||||
unverifiedVendorDbId = noCode.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deletePosByTitle(PREFIX);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.vendor.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
||||
});
|
||||
|
||||
async function makeReviewPo(title: string, withVendor = false) {
|
||||
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({
|
||||
|
|
|
|||
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
45
App/tests/unit/cancel-po-controls.test.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ refresh: vi.fn(), push: vi.fn() }) }));
|
||||
vi.mock("@/app/(portal)/po/[id]/actions", () => ({ cancelPo: vi.fn(), supersedePo: vi.fn() }));
|
||||
|
||||
import { CancelPoButton } from "@/components/po/cancel-po-controls";
|
||||
|
||||
// Regression guard: the theme only defines danger / -50 / -100 / -700, so an
|
||||
// undefined shade like bg-danger-600 renders no background → the button was
|
||||
// invisible (white text on nothing). Both cancel buttons must use `bg-danger`.
|
||||
|
||||
describe("CancelPoButton", () => {
|
||||
it("renders the trigger as a filled red (bg-danger) button with white text", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
const btn = screen.getByRole("button", { name: "Cancel PO" });
|
||||
// standalone `bg-danger` (a defined token), NOT `bg-danger-600` (undefined → invisible)
|
||||
expect(btn.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||
expect(btn.className).toContain("text-white");
|
||||
});
|
||||
|
||||
it("opens a modal whose confirm button is a visible filled danger button", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||
|
||||
const confirm = screen.getByRole("button", { name: "Cancel this PO" });
|
||||
expect(confirm.className).toMatch(/(?:^|\s)bg-danger(?:\s|$)/);
|
||||
expect(confirm.className).toContain("text-white");
|
||||
|
||||
// Keep PO is always present as the safe default.
|
||||
expect(screen.getByRole("button", { name: "Keep PO" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the confirm action disabled until 'cancel' is typed and a reason given", () => {
|
||||
render(<CancelPoButton poId="po1" poNumber="PO-1" />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel PO" }));
|
||||
|
||||
const confirm = screen.getByRole("button", { name: "Cancel this PO" }) as HTMLButtonElement;
|
||||
expect(confirm.disabled).toBe(true);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/Duplicate order/i), { target: { value: "No longer needed" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("cancel"), { target: { value: "cancel" } });
|
||||
expect(confirm.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
55
App/tests/unit/image-size.test.ts
Normal file
55
App/tests/unit/image-size.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { getImageSize, scaleToBox } from "@/lib/image-size";
|
||||
|
||||
function fakePng(width: number, height: number): Buffer {
|
||||
const b = Buffer.alloc(24);
|
||||
b[0] = 0x89; b[1] = 0x50; b[2] = 0x4e; b[3] = 0x47; // PNG signature start
|
||||
b.writeUInt32BE(width, 16);
|
||||
b.writeUInt32BE(height, 20);
|
||||
return b;
|
||||
}
|
||||
|
||||
function fakeJpeg(width: number, height: number): Buffer {
|
||||
const b = Buffer.alloc(20);
|
||||
b[0] = 0xff; b[1] = 0xd8; // SOI
|
||||
b[2] = 0xff; b[3] = 0xc0; // SOF0 marker
|
||||
b.writeUInt16BE(0x11, 4); // segment length
|
||||
b[6] = 8; // precision
|
||||
b.writeUInt16BE(height, 7);
|
||||
b.writeUInt16BE(width, 9);
|
||||
return b;
|
||||
}
|
||||
|
||||
describe("getImageSize", () => {
|
||||
it("reads PNG dimensions", () => {
|
||||
expect(getImageSize(fakePng(640, 480))).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it("reads JPEG dimensions from the SOF marker", () => {
|
||||
expect(getImageSize(fakeJpeg(1024, 768))).toEqual({ width: 1024, height: 768 });
|
||||
});
|
||||
|
||||
it("returns null for non-image data", () => {
|
||||
expect(getImageSize(Buffer.from("not an image at all"))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleToBox", () => {
|
||||
it("preserves a square aspect ratio (downscale by the binding side)", () => {
|
||||
const r = scaleToBox({ width: 200, height: 200 }, 96, 52);
|
||||
expect(r.width).toBe(r.height); // stays square — never stretched
|
||||
expect(r.height).toBeLessThanOrEqual(52);
|
||||
});
|
||||
|
||||
it("fits a wide image to the width and keeps the ratio", () => {
|
||||
const r = scaleToBox({ width: 360, height: 96 }, 165, 44);
|
||||
expect(r.width).toBeLessThanOrEqual(165);
|
||||
expect(r.height).toBeLessThanOrEqual(44);
|
||||
expect(r.width / r.height).toBeCloseTo(360 / 96, 1);
|
||||
});
|
||||
|
||||
it("keeps the watermark's landscape ratio", () => {
|
||||
const r = scaleToBox({ width: 1400, height: 1000 }, 880, 720);
|
||||
expect(r).toEqual({ width: 880, height: 629 });
|
||||
});
|
||||
});
|
||||
104
App/tests/unit/permissions-crewing.test.ts
Normal file
104
App/tests/unit/permissions-crewing.test.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
|
||||
// Verifies the crewing rows of the §6 grant matrix in the wiki
|
||||
// Crewing-Implementation-Spec are wired up exactly as written.
|
||||
describe("Crewing permissions (spec §6)", () => {
|
||||
it("SITE_STAFF holds its site-level grants", () => {
|
||||
for (const p of [
|
||||
"request_relief_cover",
|
||||
"sign_off_crew",
|
||||
"view_crew_records",
|
||||
"upload_crew_records",
|
||||
"issue_ppe",
|
||||
"apply_leave",
|
||||
"record_attendance",
|
||||
"view_attendance",
|
||||
"raise_appraisal",
|
||||
] as const) {
|
||||
expect(hasPermission("SITE_STAFF", p)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("SITE_STAFF cannot raise requisitions or decide leave or do any purchasing", () => {
|
||||
expect(hasPermission("SITE_STAFF", "raise_requisition")).toBe(false);
|
||||
expect(hasPermission("SITE_STAFF", "decide_leave")).toBe(false);
|
||||
expect(hasPermission("SITE_STAFF", "create_po")).toBe(false);
|
||||
expect(hasPermission("SITE_STAFF", "manage_ranks")).toBe(false);
|
||||
});
|
||||
|
||||
it("MPO (MANNING) has NO attendance or leave access (R5/R1)", () => {
|
||||
expect(hasPermission("MANNING", "record_attendance")).toBe(false);
|
||||
expect(hasPermission("MANNING", "view_attendance")).toBe(false);
|
||||
expect(hasPermission("MANNING", "apply_leave")).toBe(false);
|
||||
expect(hasPermission("MANNING", "decide_leave")).toBe(false);
|
||||
});
|
||||
|
||||
it("MPO sources recruitment but never gives final approvals", () => {
|
||||
expect(hasPermission("MANNING", "raise_requisition")).toBe(true);
|
||||
expect(hasPermission("MANNING", "manage_candidates")).toBe(true);
|
||||
expect(hasPermission("MANNING", "record_interview_result")).toBe(true);
|
||||
expect(hasPermission("MANNING", "verify_site_records")).toBe(true);
|
||||
// Approvals are Manager-only:
|
||||
expect(hasPermission("MANNING", "approve_salary_structure")).toBe(false);
|
||||
expect(hasPermission("MANNING", "select_candidate")).toBe(false);
|
||||
expect(hasPermission("MANNING", "approve_interview_waiver")).toBe(false);
|
||||
});
|
||||
|
||||
it("Manager owns every crewing approval gate (R1/R2/R8)", () => {
|
||||
for (const p of [
|
||||
"decide_leave",
|
||||
"approve_interview_waiver",
|
||||
"approve_salary_structure",
|
||||
"select_candidate",
|
||||
"approve_appraisal",
|
||||
"approve_wage_report",
|
||||
"generate_wage_report",
|
||||
] as const) {
|
||||
expect(hasPermission("MANAGER", p)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("Accounts verifies bank/EPF and sees wages only (R11)", () => {
|
||||
expect(hasPermission("ACCOUNTS", "verify_bank_epf")).toBe(true);
|
||||
expect(hasPermission("ACCOUNTS", "view_wage_report")).toBe(true);
|
||||
expect(hasPermission("ACCOUNTS", "view_crew_records")).toBe(true);
|
||||
expect(hasPermission("ACCOUNTS", "verify_site_records")).toBe(false);
|
||||
expect(hasPermission("ACCOUNTS", "record_attendance")).toBe(false);
|
||||
});
|
||||
|
||||
it("manage_ranks is Manager + Admin only (not SuperUser)", () => {
|
||||
expect(hasPermission("MANAGER", "manage_ranks")).toBe(true);
|
||||
expect(hasPermission("ADMIN", "manage_ranks")).toBe(true);
|
||||
expect(hasPermission("SUPERUSER", "manage_ranks")).toBe(false);
|
||||
expect(hasPermission("MANNING", "manage_ranks")).toBe(false);
|
||||
});
|
||||
|
||||
it("Auditor keeps read-only crewing visibility", () => {
|
||||
expect(hasPermission("AUDITOR", "view_requisitions")).toBe(true);
|
||||
expect(hasPermission("AUDITOR", "view_crew_records")).toBe(true);
|
||||
expect(hasPermission("AUDITOR", "view_wage_report")).toBe(true);
|
||||
expect(hasPermission("AUDITOR", "raise_requisition")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CREWING_ENABLED flag", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("defaults off when the env var is unset", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("NEXT_PUBLIC_CREWING_ENABLED", "");
|
||||
const flags = await import("@/lib/feature-flags");
|
||||
expect(flags.CREWING_ENABLED).toBe(false);
|
||||
});
|
||||
|
||||
it("is on only for the exact string \"true\"", async () => {
|
||||
vi.resetModules();
|
||||
vi.stubEnv("NEXT_PUBLIC_CREWING_ENABLED", "true");
|
||||
const flags = await import("@/lib/feature-flags");
|
||||
expect(flags.CREWING_ENABLED).toBe(true);
|
||||
});
|
||||
});
|
||||
39
App/tests/unit/po-export-layout.test.ts
Normal file
39
App/tests/unit/po-export-layout.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { signatoryLayout } from "@/lib/po-export-layout";
|
||||
|
||||
const BLOCK = 503; // px width of the A-D signatory block
|
||||
|
||||
describe("signatoryLayout", () => {
|
||||
it("centres the signature in the block", () => {
|
||||
const { sigLeft } = signatoryLayout({ blockPx: BLOCK, sig: { width: 153, height: 44 }, stamp: null });
|
||||
expect(sigLeft).not.toBeNull();
|
||||
expect(sigLeft! + 153 / 2).toBeCloseTo(BLOCK / 2, 0); // centre ≈ block centre
|
||||
});
|
||||
|
||||
it("places the stamp to the RIGHT of the signature with no overlap", () => {
|
||||
const sig = { width: 153, height: 44 };
|
||||
const stamp = { width: 67, height: 66 };
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp, gap: 10 });
|
||||
expect(stampLeft! ).toBeGreaterThanOrEqual(sigLeft! + sig.width); // starts at/after signature ends
|
||||
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK); // stays inside the block
|
||||
});
|
||||
|
||||
it("never overlaps even with the widest signature + stamp", () => {
|
||||
const sig = { width: 165, height: 44 }; // scaleToBox caps
|
||||
const stamp = { width: 80, height: 66 }; // scaleToBox caps
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig, stamp });
|
||||
expect(stampLeft!).toBeGreaterThanOrEqual(sigLeft! + sig.width);
|
||||
expect(stampLeft! + stamp.width).toBeLessThanOrEqual(BLOCK);
|
||||
});
|
||||
|
||||
it("right-aligns the stamp when there is no signature", () => {
|
||||
const { sigLeft, stampLeft } = signatoryLayout({ blockPx: BLOCK, sig: null, stamp: { width: 67, height: 66 } });
|
||||
expect(sigLeft).toBeNull();
|
||||
expect(stampLeft! + 67).toBeLessThanOrEqual(BLOCK);
|
||||
expect(stampLeft!).toBeGreaterThan(BLOCK / 2); // on the right side
|
||||
});
|
||||
|
||||
it("returns nulls when there are no images", () => {
|
||||
expect(signatoryLayout({ blockPx: BLOCK, sig: null, stamp: null })).toEqual({ sigLeft: null, stampLeft: null });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
formatCurrency, formatDate, formatDateTime,
|
||||
formatCurrency, formatCompactINR, formatDate, formatDateTime,
|
||||
generatePoNumber, PO_STATUS_LABELS, PO_STATUS_VARIANTS,
|
||||
} from "@/lib/utils";
|
||||
|
||||
|
|
@ -32,6 +32,55 @@ describe("formatCurrency", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("formatCompactINR", () => {
|
||||
it("abbreviates crore amounts with Cr", () => {
|
||||
expect(formatCompactINR(20000000)).toBe("₹2 Cr");
|
||||
});
|
||||
|
||||
it("abbreviates lakh amounts with L", () => {
|
||||
expect(formatCompactINR(4900000)).toBe("₹49 L");
|
||||
});
|
||||
|
||||
it("abbreviates thousand amounts with K", () => {
|
||||
expect(formatCompactINR(75000)).toBe("₹75 K");
|
||||
});
|
||||
|
||||
it("renders sub-thousand amounts without a suffix", () => {
|
||||
expect(formatCompactINR(500)).toBe("₹500");
|
||||
});
|
||||
|
||||
it("formats zero as ₹0", () => {
|
||||
expect(formatCompactINR(0)).toBe("₹0");
|
||||
});
|
||||
|
||||
it("trims trailing zeros but keeps significant decimals", () => {
|
||||
expect(formatCompactINR(25000000)).toBe("₹2.5 Cr");
|
||||
expect(formatCompactINR(4950000)).toBe("₹49.5 L");
|
||||
});
|
||||
|
||||
it("rounds to at most two decimals", () => {
|
||||
expect(formatCompactINR(12345678)).toBe("₹1.23 Cr");
|
||||
});
|
||||
|
||||
it("uses the right unit at boundaries", () => {
|
||||
expect(formatCompactINR(100000)).toBe("₹1 L");
|
||||
expect(formatCompactINR(10000000)).toBe("₹1 Cr");
|
||||
expect(formatCompactINR(1000)).toBe("₹1 K");
|
||||
});
|
||||
|
||||
it("accepts string input", () => {
|
||||
expect(formatCompactINR("4900000")).toBe("₹49 L");
|
||||
});
|
||||
|
||||
it("preserves the sign for negative amounts", () => {
|
||||
expect(formatCompactINR(-4900000)).toBe("-₹49 L");
|
||||
});
|
||||
|
||||
it("handles non-finite input gracefully", () => {
|
||||
expect(formatCompactINR(NaN)).toBe("₹0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDate", () => {
|
||||
it("returns a readable date string", () => {
|
||||
const result = formatDate(new Date("2026-04-29"));
|
||||
|
|
|
|||
64
App/tests/unit/vendors-table.test.tsx
Normal file
64
App/tests/unit/vendors-table.test.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { VendorsTable } from "@/app/(portal)/inventory/vendors/vendors-table";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
type Row = Parameters<typeof VendorsTable>[0]["vendors"][number];
|
||||
|
||||
const makeRow = (over: Partial<Row> = {}): Row => ({
|
||||
id: "v1",
|
||||
name: "Acme Marine Supplies",
|
||||
vendorId: "VND-001",
|
||||
gstin: null,
|
||||
address: null,
|
||||
isVerified: false,
|
||||
itemCount: 0,
|
||||
primaryContact: null,
|
||||
distanceKm: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
describe("VendorsTable — vendor id (issue #57)", () => {
|
||||
it("renders the vendorId next to the name when present", () => {
|
||||
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||
expect(screen.getByText("Acme Marine Supplies")).toBeTruthy();
|
||||
expect(screen.getByText("VND-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits the id (no placeholder) when vendorId is null", () => {
|
||||
render(<VendorsTable vendors={[makeRow({ vendorId: null })]} hasSite={false} />);
|
||||
expect(screen.queryByText("VND-001")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by vendorId", () => {
|
||||
const rows = [
|
||||
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||
];
|
||||
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||
fireEvent.change(search, { target: { value: "VND-999" } });
|
||||
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still filters by name", () => {
|
||||
const rows = [
|
||||
makeRow({ id: "v1", name: "Acme Marine Supplies", vendorId: "VND-001" }),
|
||||
makeRow({ id: "v2", name: "Beta Traders", vendorId: "VND-999" }),
|
||||
];
|
||||
render(<VendorsTable vendors={rows} hasSite={false} />);
|
||||
const search = screen.getByPlaceholderText(/Search by name/i);
|
||||
fireEvent.change(search, { target: { value: "beta" } });
|
||||
expect(screen.getByText("Beta Traders")).toBeTruthy();
|
||||
expect(screen.queryByText("Acme Marine Supplies")).toBeNull();
|
||||
});
|
||||
|
||||
it("advertises ID search in the placeholder", () => {
|
||||
render(<VendorsTable vendors={[makeRow()]} hasSite={false} />);
|
||||
expect(screen.getByPlaceholderText(/Search by name, ID, GSTIN or address/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
534
Wireframe/design-system.html
Normal file
534
Wireframe/design-system.html
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PPMS — Design System</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>
|
||||
// ── EXACT theme tokens from App/app/globals.css (@theme) ──
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: { 50:"#eff6ff",100:"#dbeafe",200:"#bfdbfe",500:"#3b82f6",600:"#2563eb",700:"#1d4ed8",800:"#1e40af" },
|
||||
success: { DEFAULT:"#16a34a", 50:"#f0fdf4",100:"#dcfce7",700:"#15803d" },
|
||||
warning: { DEFAULT:"#d97706", 50:"#fffbeb",100:"#fef3c7",700:"#b45309" },
|
||||
danger: { DEFAULT:"#dc2626", 50:"#fef2f2",100:"#fee2e2",700:"#b91c1c" },
|
||||
neutral: { 50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",600:"#525252",700:"#404040",800:"#262626",900:"#171717" },
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter','ui-sans-serif','system-ui','sans-serif'],
|
||||
mono: ['"JetBrains Mono"','ui-monospace','monospace'],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
body { font-family:'Inter',ui-sans-serif,system-ui,sans-serif; }
|
||||
code, pre, .mono { font-family:'JetBrains Mono',ui-monospace,monospace; }
|
||||
.toc a.active { color:#1d4ed8; font-weight:600; }
|
||||
::-webkit-scrollbar { width:9px; height:9px; }
|
||||
::-webkit-scrollbar-thumb { background:#d4d4d4; border-radius:5px; }
|
||||
/* click-to-copy snippet */
|
||||
.snip { position:relative; }
|
||||
.snip code { cursor:copy; }
|
||||
.snip .copied { position:absolute; top:6px; right:8px; font-size:10px; color:#16a34a; opacity:0; transition:opacity .2s; }
|
||||
.snip.show .copied { opacity:1; }
|
||||
html { scroll-behavior:smooth; scroll-padding-top:84px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 text-neutral-900">
|
||||
|
||||
<!-- Top bar -->
|
||||
<header class="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-neutral-200 bg-white/90 px-6 backdrop-blur">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600">
|
||||
<svg class="h-4 w-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="22" x2="12" y2="8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/></svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-semibold leading-tight">PPMS Design System</p>
|
||||
<p class="text-xs text-neutral-400 leading-tight">Pelagia Purchase Management System · v1</p>
|
||||
</div>
|
||||
<div class="ml-auto hidden sm:flex items-center gap-2 text-xs text-neutral-400">
|
||||
<span class="rounded-full bg-neutral-100 px-2 py-0.5">Next.js 15</span>
|
||||
<span class="rounded-full bg-neutral-100 px-2 py-0.5">Tailwind v4</span>
|
||||
<span class="rounded-full bg-neutral-100 px-2 py-0.5">lucide-react</span>
|
||||
<span class="rounded-full bg-neutral-100 px-2 py-0.5">recharts</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mx-auto flex max-w-7xl gap-8 px-6">
|
||||
<!-- TOC -->
|
||||
<nav class="toc sticky top-16 hidden h-[calc(100vh-4rem)] w-52 shrink-0 overflow-y-auto py-8 text-sm lg:block">
|
||||
<p class="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">Foundations</p>
|
||||
<a href="#overview" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Overview</a>
|
||||
<a href="#color" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Color</a>
|
||||
<a href="#type" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Typography</a>
|
||||
<a href="#tokens" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Radius · Shadow · Space</a>
|
||||
<a href="#icons" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Icons</a>
|
||||
<p class="mb-2 mt-4 px-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">Layout</p>
|
||||
<a href="#shell" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">App shell</a>
|
||||
<a href="#pageheader" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Page header</a>
|
||||
<p class="mb-2 mt-4 px-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">Components</p>
|
||||
<a href="#buttons" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Buttons</a>
|
||||
<a href="#badges" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Badges</a>
|
||||
<a href="#status" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">PO status badges</a>
|
||||
<a href="#cards" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Cards & KPIs</a>
|
||||
<a href="#forms" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Forms</a>
|
||||
<a href="#segmented" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Tabs & segmented</a>
|
||||
<a href="#table" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Tables</a>
|
||||
<a href="#alerts" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Alerts & dialog</a>
|
||||
<a href="#charts" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Charts</a>
|
||||
<p class="mb-2 mt-4 px-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">Conventions</p>
|
||||
<a href="#format" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Formatting</a>
|
||||
<a href="#voice" class="block rounded px-2 py-1.5 text-neutral-600 hover:bg-neutral-100">Do & don't</a>
|
||||
</nav>
|
||||
|
||||
<!-- Content -->
|
||||
<main class="min-w-0 flex-1 space-y-16 py-8">
|
||||
|
||||
<!-- OVERVIEW -->
|
||||
<section id="overview">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">Design System</h1>
|
||||
<p class="mt-2 max-w-2xl text-sm leading-relaxed text-neutral-600">
|
||||
The visual language of the Pelagia Purchase Management System — an internal PO tool for a maritime company.
|
||||
Everything here is lifted from the live codebase (<code class="rounded bg-neutral-100 px-1 text-xs">app/globals.css</code> +
|
||||
<code class="rounded bg-neutral-100 px-1 text-xs">components/ui/*</code>), so screens built from these tokens drop straight in.
|
||||
</p>
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 text-sm">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Framework</p><p class="mt-1 font-medium">Next.js 15 · App Router</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Styling</p><p class="mt-1 font-medium">Tailwind CSS v4</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Primitives</p><p class="mt-1 font-medium">Radix UI · CVA</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Charts · Icons</p><p class="mt-1 font-medium">recharts · lucide</p></div>
|
||||
</div>
|
||||
<p class="mt-4 text-xs text-neutral-400">Tip: click any <code class="rounded bg-neutral-100 px-1">code snippet</code> to copy it.</p>
|
||||
</section>
|
||||
|
||||
<!-- COLOR -->
|
||||
<section id="color">
|
||||
<h2 class="text-lg font-semibold">Color</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-neutral-600">Five ramps. <b>Primary</b> = actions, links, active nav. <b>Success</b> = approved / paid. <b>Warning</b> = needs action (edits, vendor ID, partial). <b>Danger</b> = rejected / destructive. <b>Neutral</b> = all structure, text and borders.</p>
|
||||
|
||||
<div class="mt-5 space-y-5" id="ramps"></div>
|
||||
</section>
|
||||
|
||||
<!-- TYPOGRAPHY -->
|
||||
<section id="type">
|
||||
<h2 class="text-lg font-semibold">Typography</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600"><b>Inter</b> for UI, <b>JetBrains Mono</b> for codes & numbers (PO numbers, accounting codes).</p>
|
||||
<div class="mt-4 divide-y divide-neutral-100 rounded-lg border border-neutral-200 bg-white">
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-2xl font-semibold text-neutral-900">Page title</p><code class="text-xs text-neutral-400">text-2xl font-semibold</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-lg font-semibold text-neutral-900">Section heading</p><code class="text-xs text-neutral-400">text-lg font-semibold</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-base font-semibold text-neutral-900">Card title</p><code class="text-xs text-neutral-400">text-base font-semibold</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-sm text-neutral-700">Body / table text — the workhorse size for the whole app.</p><code class="text-xs text-neutral-400">text-sm</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-sm text-neutral-500">Muted description & helper text</p><code class="text-xs text-neutral-400">text-sm text-neutral-500</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Eyebrow / label</p><code class="text-xs text-neutral-400">text-xs uppercase tracking-wider</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="mono text-sm text-neutral-700">PMS/HNR1/9000/2024-25</p><code class="text-xs text-neutral-400">font-mono</code></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TOKENS -->
|
||||
<section id="tokens">
|
||||
<h2 class="text-lg font-semibold">Radius · Shadow · Spacing</h2>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Radius</p>
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-md border border-neutral-300 bg-neutral-50"></div><code class="mt-1 block text-[11px] text-neutral-400">rounded-md</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg border border-neutral-300 bg-neutral-50"></div><code class="mt-1 block text-[11px] text-neutral-400">rounded-lg</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-full border border-neutral-300 bg-neutral-50"></div><code class="mt-1 block text-[11px] text-neutral-400">rounded-full</code></div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-neutral-500"><b>lg</b> for cards/buttons/inputs, <b>md</b> for nav items, <b>full</b> for badges & avatars.</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Elevation</p>
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg border border-neutral-200 bg-white"></div><code class="mt-1 block text-[11px] text-neutral-400">border</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg border border-neutral-200 bg-white shadow-sm"></div><code class="mt-1 block text-[11px] text-neutral-400">shadow-sm</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg bg-white shadow-lg"></div><code class="mt-1 block text-[11px] text-neutral-400">shadow-lg</code></div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-neutral-500">Flat by default — a 1px <b>border-neutral-200</b> separates surfaces. <b>shadow-sm</b> on cards, <b>shadow-lg</b> only for popovers/dialogs.</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Spacing rhythm</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:16px"></div><code class="text-[11px] text-neutral-400">gap-2 · 8px</code></div>
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:24px"></div><code class="text-[11px] text-neutral-400">gap-3 · 12px (rows)</code></div>
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:40px"></div><code class="text-[11px] text-neutral-400">p-5 · 20px (cards)</code></div>
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:48px"></div><code class="text-[11px] text-neutral-400">p-6 / gap-6 · 24px</code></div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-neutral-500">Page padding <b>p-6</b>; cards <b>p-5</b>/<b>p-6</b>; controls gap <b>gap-2/3</b>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ICONS -->
|
||||
<section id="icons">
|
||||
<h2 class="text-lg font-semibold">Icons</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600"><b>lucide-react</b>, stroke 2, sized <code class="rounded bg-neutral-100 px-1 text-xs">h-4 w-4</code> inline / <code class="rounded bg-neutral-100 px-1 text-xs">h-5 w-5</code> standalone. They inherit text color (<code class="rounded bg-neutral-100 px-1 text-xs">currentColor</code>).</p>
|
||||
<div class="mt-4 flex flex-wrap gap-3 rounded-lg border border-neutral-200 bg-white p-5 text-neutral-600" id="iconrow"></div>
|
||||
</section>
|
||||
|
||||
<!-- APP SHELL -->
|
||||
<section id="shell">
|
||||
<h2 class="text-lg font-semibold">App shell</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-neutral-600">Fixed <b>w-60</b> sidebar (white, <code class="rounded bg-neutral-100 px-1 text-xs">border-r</code>) + <b>h-16</b> top bar + scrollable <code class="rounded bg-neutral-100 px-1 text-xs">main</code> on <code class="rounded bg-neutral-100 px-1 text-xs">bg-neutral-50</code> with <b>p-6</b>. Nav links: <code class="rounded bg-neutral-100 px-1 text-xs">rounded-md px-3 py-2 text-sm font-medium</code>; active = <code class="rounded bg-neutral-100 px-1 text-xs">bg-primary-50 text-primary-700</code>; section eyebrow = <code class="rounded bg-neutral-100 px-1 text-xs">text-xs uppercase tracking-wider text-neutral-400</code>.</p>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200">
|
||||
<div class="flex h-80 bg-neutral-50">
|
||||
<aside class="flex w-56 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
||||
<div class="flex h-14 items-center gap-2.5 border-b border-neutral-200 px-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600"><svg class="h-4 w-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="22" x2="12" y2="8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/></svg></div>
|
||||
<span class="text-sm font-semibold">PPMS</span>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-0.5 p-3 text-sm">
|
||||
<a class="flex items-center gap-3 rounded-md bg-primary-50 px-3 py-2 font-medium text-primary-700"><span class="h-4 w-4 rounded bg-primary-600/20"></span>Dashboard</a>
|
||||
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100"><span class="h-4 w-4 rounded bg-neutral-300"></span>New PO</a>
|
||||
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100"><span class="h-4 w-4 rounded bg-neutral-300"></span>Approvals</a>
|
||||
<p class="px-3 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-neutral-400">Administration</p>
|
||||
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100"><span class="h-4 w-4 rounded bg-neutral-300"></span>Users</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<div class="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-5">
|
||||
<span class="text-sm font-medium text-neutral-500">Dashboard</span>
|
||||
<div class="flex items-center gap-2"><span class="h-7 w-7 rounded-full bg-neutral-200"></span></div>
|
||||
</div>
|
||||
<div class="flex-1 space-y-3 overflow-hidden p-5">
|
||||
<div class="h-4 w-40 rounded bg-neutral-200"></div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="h-16 rounded-lg border border-neutral-200 bg-white"></div>
|
||||
<div class="h-16 rounded-lg border border-neutral-200 bg-white"></div>
|
||||
<div class="h-16 rounded-lg border border-neutral-200 bg-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PAGE HEADER -->
|
||||
<section id="pageheader">
|
||||
<h2 class="text-lg font-semibold">Page header</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Title + muted subtitle on the left, primary action on the right.</p>
|
||||
<div class="mt-4 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-neutral-900">Purchase Orders</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500">Create, track and approve orders across the fleet.</p>
|
||||
</div>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white hover:bg-primary-700">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>New PO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snip mt-2"><pre class="overflow-x-auto rounded-lg bg-neutral-900 p-3 text-xs leading-relaxed text-neutral-100"><code><div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-neutral-900">Title</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500">Subtitle</p>
|
||||
</div>
|
||||
<Button>New PO</Button>
|
||||
</div></code><span class="copied">copied ✓</span></pre></div>
|
||||
</section>
|
||||
|
||||
<!-- BUTTONS -->
|
||||
<section id="buttons">
|
||||
<h2 class="text-lg font-semibold">Buttons</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">From <code class="rounded bg-neutral-100 px-1 text-xs">components/ui/button.tsx</code> (CVA). Base: <code class="rounded bg-neutral-100 px-1 text-xs">inline-flex items-center gap-2 rounded-lg text-sm font-medium</code> + focus ring.</p>
|
||||
<div class="mt-4 space-y-4 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white hover:bg-primary-700">Default</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Secondary</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-success px-4 text-sm font-medium text-white hover:opacity-90">Success</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg border border-warning bg-warning-50 px-4 text-sm font-medium text-warning-700 hover:bg-warning-100">Warning</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-danger px-4 text-sm font-medium text-white hover:opacity-90">Destructive</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg px-4 text-sm font-medium text-neutral-700 hover:bg-neutral-100">Ghost</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg px-1 text-sm font-medium text-primary-600 underline-offset-4 hover:underline">Link</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white opacity-60" disabled>Disabled</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 border-t border-neutral-100 pt-4">
|
||||
<button class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary-600 px-3 text-xs font-medium text-white">sm · h-8</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white">md · h-10</button>
|
||||
<button class="inline-flex h-11 items-center gap-2 rounded-lg bg-primary-600 px-5 text-sm font-medium text-white">lg · h-11</button>
|
||||
<button class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-700"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snip mt-2"><pre class="overflow-x-auto rounded-lg bg-neutral-900 p-3 text-xs text-neutral-100"><code><Button variant="default|secondary|success|warning|destructive|ghost|link" size="sm|md|lg|icon"></code><span class="copied">copied ✓</span></pre></div>
|
||||
</section>
|
||||
|
||||
<!-- BADGES -->
|
||||
<section id="badges">
|
||||
<h2 class="text-lg font-semibold">Badges</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">From <code class="rounded bg-neutral-100 px-1 text-xs">components/ui/badge.tsx</code>. Base: <code class="rounded bg-neutral-100 px-1 text-xs">rounded-full px-2.5 py-0.5 text-xs font-medium</code>.</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<span class="rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-medium text-primary-700">default</span>
|
||||
<span class="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">secondary</span>
|
||||
<span class="rounded-full bg-success-100 px-2.5 py-0.5 text-xs font-medium text-success-700">success</span>
|
||||
<span class="rounded-full bg-warning-100 px-2.5 py-0.5 text-xs font-medium text-warning-700">warning</span>
|
||||
<span class="rounded-full bg-danger-100 px-2.5 py-0.5 text-xs font-medium text-danger-700">danger</span>
|
||||
<span class="rounded-full border border-neutral-300 px-2.5 py-0.5 text-xs font-medium text-neutral-600">outline</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PO STATUS -->
|
||||
<section id="status">
|
||||
<h2 class="text-lg font-semibold">PO status badges</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-neutral-600">The 13 lifecycle states map to badge variants in <code class="rounded bg-neutral-100 px-1 text-xs">lib/utils.ts</code> (<code class="rounded bg-neutral-100 px-1 text-xs">PO_STATUS_LABELS</code> / <code class="rounded bg-neutral-100 px-1 text-xs">PO_STATUS_VARIANTS</code>). Reuse this mapping — don't invent new status colors.</p>
|
||||
<div class="mt-4 grid gap-2 rounded-lg border border-neutral-200 bg-white p-5 sm:grid-cols-2 lg:grid-cols-3" id="statusgrid"></div>
|
||||
</section>
|
||||
|
||||
<!-- CARDS -->
|
||||
<section id="cards">
|
||||
<h2 class="text-lg font-semibold">Cards & KPI stats</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Card = <code class="rounded bg-neutral-100 px-1 text-xs">rounded-lg border border-neutral-200 bg-white shadow-sm</code>. KPI stat cards drop the shadow and use <b>p-4/5</b>.</p>
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white shadow-sm">
|
||||
<div class="flex flex-col gap-1 p-6 pb-4">
|
||||
<h3 class="text-base font-semibold text-neutral-900">Vendor details</h3>
|
||||
<p class="text-sm text-neutral-500">GST-verified supplier on file.</p>
|
||||
</div>
|
||||
<div class="space-y-1 p-6 pt-0 text-sm text-neutral-600"><p>Acme Marine Supplies Pvt Ltd</p><p class="mono text-xs text-neutral-400">GSTIN 27ABCDE1234F1Z5</p></div>
|
||||
<div class="flex items-center gap-2 p-6 pt-0"><button class="inline-flex h-8 items-center rounded-lg bg-primary-600 px-3 text-xs font-medium text-white">Edit</button><button class="inline-flex h-8 items-center rounded-lg border border-neutral-300 bg-white px-3 text-xs font-medium text-neutral-700">View POs</button></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Open POs</p><p class="mt-1.5 text-xl font-semibold">24</p><p class="mt-0.5 text-xs text-success-700">▲ 8% vs last month</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Awaiting approval</p><p class="mt-1.5 text-xl font-semibold">7</p><p class="mt-0.5 text-xs text-neutral-400">across 3 vessels</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Spend (FY)</p><p class="mt-1.5 text-xl font-semibold">₹2.4 Cr</p><p class="mt-0.5 text-xs text-neutral-400">FY 2025–26</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Overdue</p><p class="mt-1.5 text-xl font-semibold">2</p><p class="mt-0.5 text-xs text-danger-700">needs attention</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FORMS -->
|
||||
<section id="forms">
|
||||
<h2 class="text-lg font-semibold">Forms</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Inputs from <code class="rounded bg-neutral-100 px-1 text-xs">components/ui/input.tsx</code>: <code class="rounded bg-neutral-100 px-1 text-xs">h-10 rounded-lg border-neutral-300</code>, focus <code class="rounded bg-neutral-100 px-1 text-xs">border-primary-500 ring-2 ring-primary-500/20</code>.</p>
|
||||
<div class="mt-4 grid max-w-2xl gap-4 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">PO title</label><input placeholder="e.g. Engine spares — MV Pelagia Star" class="h-10 w-full rounded-lg border border-neutral-300 px-3 text-sm placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" /></div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Cost centre</label><select class="h-10 w-full rounded-lg border border-neutral-300 bg-white px-3 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"><option>MV Pelagia Star</option><option>MV Ocean Dawn</option></select></div>
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Date required</label><input type="date" class="h-10 w-full rounded-lg border border-neutral-300 px-3 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20" /></div>
|
||||
</div>
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Notes</label><textarea rows="3" placeholder="Optional…" class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"></textarea></div>
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Disabled</label><input disabled value="Read-only value" class="h-10 w-full cursor-not-allowed rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-sm text-neutral-500 opacity-60" /></div>
|
||||
<p class="-mt-1 text-xs text-danger-700">Inline validation error sits under the field in <code class="rounded bg-neutral-100 px-1">text-danger-700</code>.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEGMENTED / TABS -->
|
||||
<section id="segmented">
|
||||
<h2 class="text-lg font-semibold">Tabs & segmented controls</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Toolbar filters use a pill segmented control; page sections use an underline tab bar.</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-6 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div class="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
|
||||
<button class="rounded-md bg-primary-600 px-3 py-1 font-medium text-white shadow-sm">Monthly</button>
|
||||
<button class="rounded-md px-3 py-1 font-medium text-neutral-500 hover:text-neutral-800">Yearly</button>
|
||||
<button class="rounded-md px-3 py-1 font-medium text-neutral-500 hover:text-neutral-800">Weekly</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 border-b border-neutral-200 text-sm">
|
||||
<button class="-mb-px border-b-2 border-primary-600 pb-2 font-medium text-primary-700">Details</button>
|
||||
<button class="-mb-px border-b-2 border-transparent pb-2 font-medium text-neutral-500 hover:text-neutral-800">Line items</button>
|
||||
<button class="-mb-px border-b-2 border-transparent pb-2 font-medium text-neutral-500 hover:text-neutral-800">History</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- TABLE -->
|
||||
<section id="table">
|
||||
<h2 class="text-lg font-semibold">Tables</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Header row <code class="rounded bg-neutral-100 px-1 text-xs">bg-neutral-50</code> with <code class="rounded bg-neutral-100 px-1 text-xs">text-xs uppercase tracking-wider text-neutral-400</code>; body rows <code class="rounded bg-neutral-100 px-1 text-xs">divide-y divide-neutral-100</code>, hover <code class="rounded bg-neutral-100 px-1 text-xs">hover:bg-neutral-50</code>; numbers <code class="rounded bg-neutral-100 px-1 text-xs">tabular-nums text-right</code>.</p>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
<tr><th class="px-5 py-3">PO Number</th><th class="px-5 py-3">Vessel</th><th class="px-5 py-3">Status</th><th class="px-5 py-3 text-right">Amount</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-100">
|
||||
<tr class="hover:bg-neutral-50"><td class="px-5 py-3 mono text-xs">PMS/HNR1/9001/2025-26</td><td class="px-5 py-3">MV Pelagia Star</td><td class="px-5 py-3"><span class="rounded-full bg-success-100 px-2.5 py-0.5 text-xs font-medium text-success-700">Approved</span></td><td class="px-5 py-3 text-right tabular-nums">₹4,82,000</td></tr>
|
||||
<tr class="hover:bg-neutral-50"><td class="px-5 py-3 mono text-xs">PMS/OCD2/9002/2025-26</td><td class="px-5 py-3">MV Ocean Dawn</td><td class="px-5 py-3"><span class="rounded-full bg-warning-100 px-2.5 py-0.5 text-xs font-medium text-warning-700">Edits Requested</span></td><td class="px-5 py-3 text-right tabular-nums">₹1,15,500</td></tr>
|
||||
<tr class="hover:bg-neutral-50"><td class="px-5 py-3 mono text-xs">PMS/CRT3/9003/2025-26</td><td class="px-5 py-3">MV Coral Trident</td><td class="px-5 py-3"><span class="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">Closed</span></td><td class="px-5 py-3 text-right tabular-nums">₹92,300</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ALERTS / DIALOG -->
|
||||
<section id="alerts">
|
||||
<h2 class="text-lg font-semibold">Alerts & dialog</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Inline callouts use the semantic <b>-50</b> tint + <b>-700</b> text + matching border.</p>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex items-start gap-3 rounded-lg border border-primary-200 bg-primary-50 p-4 text-sm text-primary-800"><span class="font-medium">ℹ Info</span><span>Imported POs are created directly in the Closed state.</span></div>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-success-100 bg-success-50 p-4 text-sm text-success-700"><span class="font-medium">✓ Success</span><span>Purchase order approved and sent for payment.</span></div>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-warning-100 bg-warning-50 p-4 text-sm text-warning-700"><span class="font-medium">⚠ Warning</span><span>This vendor is unverified — verify before assigning a vendor code.</span></div>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-danger-100 bg-danger-50 p-4 text-sm text-danger-700"><span class="font-medium">✕ Error</span><span>Payment date cannot be in the future.</span></div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p class="mb-2 text-sm font-medium text-neutral-700">Dialog</p>
|
||||
<div class="relative overflow-hidden rounded-lg border border-neutral-200 bg-neutral-100 p-8">
|
||||
<div class="mx-auto max-w-sm rounded-lg border border-neutral-200 bg-white p-6 shadow-lg">
|
||||
<h3 class="text-base font-semibold text-neutral-900">Delete accounting code?</h3>
|
||||
<p class="mt-1 text-sm text-neutral-500">This removes <b>5110 · Fuel & Lubricants</b>. This action can't be undone.</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button class="inline-flex h-10 items-center rounded-lg border border-neutral-300 bg-white px-4 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
||||
<button class="inline-flex h-10 items-center rounded-lg bg-danger px-4 text-sm font-medium text-white hover:opacity-90">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CHARTS -->
|
||||
<section id="charts">
|
||||
<h2 class="text-lg font-semibold">Charts</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-neutral-600"><b>recharts</b> inside a standard card. Grid <code class="rounded bg-neutral-100 px-1 text-xs">strokeDasharray="3 3"</code> in <code class="rounded bg-neutral-100 px-1 text-xs">#f0f0f0</code>; axis ticks <code class="rounded bg-neutral-100 px-1 text-xs">fontSize 11, #737373</code>; bars <code class="rounded bg-neutral-100 px-1 text-xs">radius</code>, lines <code class="rounded bg-neutral-100 px-1 text-xs">strokeWidth 2</code>. Y-axis money formatted via <code class="rounded bg-neutral-100 px-1 text-xs">formatCompactINR</code>.</p>
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-4 text-sm font-semibold">Spend by cost centre</p>
|
||||
<svg viewBox="0 0 320 170" class="w-full"><line x1="34" y1="10" x2="34" y2="140" stroke="#e5e5e5"/><line x1="34" y1="140" x2="320" y2="140" stroke="#e5e5e5"/>
|
||||
<g><rect x="50" y="40" width="34" height="100" rx="3" fill="#2563eb"/><rect x="104" y="64" width="34" height="76" rx="3" fill="#2563eb"/><rect x="158" y="80" width="34" height="60" rx="3" fill="#2563eb"/><rect x="212" y="98" width="34" height="42" rx="3" fill="#2563eb"/><rect x="266" y="112" width="34" height="28" rx="3" fill="#2563eb"/></g>
|
||||
<g font-size="9" fill="#737373"><text x="0" y="44">₹3Cr</text><text x="0" y="92">₹1.5Cr</text><text x="6" y="140">₹0</text></g>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-4 text-sm font-semibold">Monthly trend</p>
|
||||
<svg viewBox="0 0 320 170" class="w-full"><line x1="34" y1="10" x2="34" y2="140" stroke="#e5e5e5"/><line x1="34" y1="140" x2="320" y2="140" stroke="#e5e5e5"/>
|
||||
<polyline fill="none" stroke="#2563eb" stroke-width="2" points="46,70 92,52 138,96 184,60 230,108 276,46 308,72"/>
|
||||
<polyline fill="none" stroke="#16a34a" stroke-width="2" points="46,110 92,100 138,118 184,88 230,124 276,96 308,104"/>
|
||||
<g fill="#2563eb"><circle cx="92" cy="52" r="2.5"/><circle cx="276" cy="46" r="2.5"/></g>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-sm font-semibold">Multi-series palette</p>
|
||||
<div class="flex flex-wrap gap-2" id="chartpalette"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FORMATTING -->
|
||||
<section id="format">
|
||||
<h2 class="text-lg font-semibold">Formatting conventions</h2>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200 bg-white text-sm">
|
||||
<table class="w-full">
|
||||
<tbody class="divide-y divide-neutral-100">
|
||||
<tr><td class="w-1/3 px-5 py-3 font-medium text-neutral-700">Currency</td><td class="px-5 py-3 text-neutral-600">Indian locale, INR — <code class="mono text-xs">formatCurrency()</code> → ₹4,82,000</td></tr>
|
||||
<tr><td class="px-5 py-3 font-medium text-neutral-700">Compact money</td><td class="px-5 py-3 text-neutral-600"><code class="mono text-xs">formatCompactINR()</code> → ₹2 Cr · ₹49 L · ₹75 K (lakh/crore scale)</td></tr>
|
||||
<tr><td class="px-5 py-3 font-medium text-neutral-700">Dates</td><td class="px-5 py-3 text-neutral-600"><code class="mono text-xs">formatDate()</code> → Jun 22, 2026 · financial year is Apr–Mar (<code class="mono text-xs">2025-26</code>)</td></tr>
|
||||
<tr><td class="px-5 py-3 font-medium text-neutral-700">PO numbers</td><td class="px-5 py-3 text-neutral-600">Mono font, <code class="mono text-xs">COMPANY/VESSEL/ID/FY</code> → PMS/HNR1/9000/2024-25</td></tr>
|
||||
<tr><td class="px-5 py-3 font-medium text-neutral-700">Accounting codes</td><td class="px-5 py-3 text-neutral-600">6-digit mono codes; graphs label headings/sub-headings by <b>name</b>, leaves by <b>code</b></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- DO / DON'T -->
|
||||
<section id="voice" class="pb-12">
|
||||
<h2 class="text-lg font-semibold">Do & don't</h2>
|
||||
<div class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
<div class="rounded-lg border border-success-100 bg-success-50 p-5">
|
||||
<p class="mb-2 text-sm font-semibold text-success-700">Do</p>
|
||||
<ul class="space-y-1.5 text-sm text-neutral-700">
|
||||
<li>• Separate surfaces with a 1px <code class="rounded bg-white px-1 text-xs">border-neutral-200</code>, not heavy shadows.</li>
|
||||
<li>• Reuse <code class="rounded bg-white px-1 text-xs">PO_STATUS_VARIANTS</code> for any PO status pill.</li>
|
||||
<li>• Keep one primary action per view; everything else secondary/ghost.</li>
|
||||
<li>• Right-align and <code class="rounded bg-white px-1 text-xs">tabular-nums</code> all money columns.</li>
|
||||
<li>• Use semantic color only for meaning (success=paid, warning=action, danger=stop).</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="rounded-lg border border-danger-100 bg-danger-50 p-5">
|
||||
<p class="mb-2 text-sm font-semibold text-danger-700">Don't</p>
|
||||
<ul class="space-y-1.5 text-sm text-neutral-700">
|
||||
<li>• Don't introduce new hues — stay within the five ramps.</li>
|
||||
<li>• Don't use primary blue for decoration; it signals an action.</li>
|
||||
<li>• Don't mix radii — <code class="rounded bg-white px-1 text-xs">rounded-lg</code> for boxes, <code class="rounded bg-white px-1 text-xs">rounded-full</code> for pills.</li>
|
||||
<li>• Don't crowd charts — one chart per card, generous whitespace.</li>
|
||||
<li>• Don't hardcode ₹ formatting — use the shared helpers.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── data-driven sections ── */
|
||||
const RAMPS = [
|
||||
["Primary","primary",[["50","#eff6ff"],["100","#dbeafe"],["200","#bfdbfe"],["500","#3b82f6"],["600","#2563eb"],["700","#1d4ed8"],["800","#1e40af"]],"600"],
|
||||
["Success","success",[["50","#f0fdf4"],["100","#dcfce7"],["DEFAULT","#16a34a"],["700","#15803d"]],"DEFAULT"],
|
||||
["Warning","warning",[["50","#fffbeb"],["100","#fef3c7"],["DEFAULT","#d97706"],["700","#b45309"]],"DEFAULT"],
|
||||
["Danger","danger",[["50","#fef2f2"],["100","#fee2e2"],["DEFAULT","#dc2626"],["700","#b91c1c"]],"DEFAULT"],
|
||||
["Neutral","neutral",[["50","#fafafa"],["100","#f5f5f5"],["200","#e5e5e5"],["300","#d4d4d4"],["400","#a3a3a3"],["500","#737373"],["600","#525252"],["700","#404040"],["800","#262626"],["900","#171717"]],"900"],
|
||||
];
|
||||
document.getElementById("ramps").innerHTML = RAMPS.map(([name,key,stops])=>`
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-baseline gap-2"><span class="text-sm font-semibold">${name}</span><code class="text-xs text-neutral-400">--color-${key}-*</code></div>
|
||||
<div class="grid grid-cols-4 gap-2 sm:grid-cols-7 lg:grid-cols-10">
|
||||
${stops.map(([s,hex])=>{
|
||||
const dark = ["600","700","800","900","DEFAULT"].includes(s) && !(key==="neutral"&&["50","100","200","300","400"].includes(s));
|
||||
const cls = s==="DEFAULT"? key : `${key}-${s}`;
|
||||
return `<div class="overflow-hidden rounded-lg border border-neutral-200">
|
||||
<div class="h-12" style="background:${hex}"></div>
|
||||
<div class="bg-white px-1.5 py-1"><p class="text-[11px] font-medium">${s}</p><p class="mono text-[10px] text-neutral-400">${hex}</p><p class="mono text-[9px] text-neutral-400">${cls}</p></div>
|
||||
</div>`;}).join("")}
|
||||
</div>
|
||||
</div>`).join("");
|
||||
|
||||
/* lucide-style icon set (inline svg paths, stroke 2) */
|
||||
const ICONS = {
|
||||
dashboard:'<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>',
|
||||
file:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',
|
||||
plus:'<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
|
||||
check:'<polyline points="20 6 9 17 4 12"/>',
|
||||
ship:'<path d="M12 22V8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/>',
|
||||
users:'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>',
|
||||
card:'<rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
|
||||
download:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
|
||||
search:'<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
bell:'<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',
|
||||
chart:'<path d="M3 3v18h18"/><rect x="7" y="9" width="3" height="9"/><rect x="14" y="5" width="3" height="13"/>',
|
||||
settings:'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
|
||||
};
|
||||
document.getElementById("iconrow").innerHTML = Object.entries(ICONS).map(([n,p])=>`
|
||||
<div class="flex flex-col items-center gap-1 w-16">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${p}</svg>
|
||||
<span class="text-[10px] text-neutral-400">${n}</span>
|
||||
</div>`).join("");
|
||||
|
||||
/* PO status mapping (from lib/utils.ts) */
|
||||
const VARIANT_CLASS = {
|
||||
default:"bg-primary-100 text-primary-700", secondary:"bg-neutral-100 text-neutral-700",
|
||||
success:"bg-success-100 text-success-700", warning:"bg-warning-100 text-warning-700",
|
||||
danger:"bg-danger-100 text-danger-700", outline:"border border-neutral-300 text-neutral-600",
|
||||
};
|
||||
const PO_STATUS = [
|
||||
["DRAFT","Draft","outline"],["SUBMITTED","Submitted","secondary"],["MGR_REVIEW","Under Review","secondary"],
|
||||
["VENDOR_ID_PENDING","Vendor ID Pending","warning"],["EDITS_REQUESTED","Edits Requested","warning"],
|
||||
["REJECTED","Rejected","danger"],["MGR_APPROVED","Approved","success"],["SENT_FOR_PAYMENT","Sent for Payment","default"],
|
||||
["PARTIALLY_PAID","Partially Paid","warning"],["PAID_DELIVERED","Paid","success"],
|
||||
["PARTIALLY_CLOSED","Partially Received","warning"],["CLOSED","Closed","secondary"],["CANCELLED","Cancelled","danger"],
|
||||
];
|
||||
document.getElementById("statusgrid").innerHTML = PO_STATUS.map(([k,label,v])=>`
|
||||
<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1.5">
|
||||
<span class="rounded-full px-2.5 py-0.5 text-xs font-medium ${VARIANT_CLASS[v]}">${label}</span>
|
||||
<code class="text-[10px] text-neutral-400">${k}</code>
|
||||
</div>`).join("");
|
||||
|
||||
/* chart palette */
|
||||
const CHART_COLORS=["#2563eb","#16a34a","#9333ea","#ea580c","#0891b2","#dc2626","#ca8a04","#4f46e5"];
|
||||
document.getElementById("chartpalette").innerHTML = CHART_COLORS.map((c,i)=>`
|
||||
<div class="flex items-center gap-1.5 rounded-md border border-neutral-200 px-2 py-1"><span class="h-3.5 w-3.5 rounded" style="background:${c}"></span><code class="text-[10px] text-neutral-500">${c}</code></div>`).join("");
|
||||
|
||||
/* TOC scroll-spy */
|
||||
const links=[...document.querySelectorAll('.toc a')];
|
||||
const map=Object.fromEntries(links.map(a=>[a.getAttribute('href').slice(1),a]));
|
||||
const io=new IntersectionObserver(es=>{es.forEach(e=>{ if(e.isIntersecting){ links.forEach(l=>l.classList.remove('active')); map[e.target.id]?.classList.add('active'); }});},{rootMargin:'-80px 0px -70% 0px'});
|
||||
document.querySelectorAll('main section[id]').forEach(s=>io.observe(s));
|
||||
|
||||
/* click-to-copy snippets */
|
||||
document.querySelectorAll('.snip code').forEach(c=>{
|
||||
c.addEventListener('click',()=>{ navigator.clipboard?.writeText(c.innerText); const w=c.closest('.snip'); w.classList.add('show'); setTimeout(()=>w.classList.remove('show'),1200); });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
33
Wireframe/ds-bundle/components/alerts.html
Normal file
33
Wireframe/ds-bundle/components/alerts.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!-- @dsCard group="Components" name="Alerts & dialog" subtitle="semantic -50 tint + -700 text · modal" width="700" height="440" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Alerts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{50:"#eff6ff",200:"#bfdbfe",800:"#1e40af"},success:{50:"#f0fdf4",100:"#dcfce7",700:"#15803d"},warning:{50:"#fffbeb",100:"#fef3c7",700:"#b45309"},danger:{DEFAULT:"#dc2626",50:"#fef2f2",100:"#fee2e2",700:"#b91c1c"},neutral:{100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",500:"#737373",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Alerts & dialog</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Inline callouts use the semantic <b>-50</b> tint + <b>-700</b> text + matching border.</p>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div class="flex items-start gap-3 rounded-lg border border-primary-200 bg-primary-50 p-4 text-sm text-primary-800"><span class="font-medium">ℹ Info</span><span>Imported POs are created directly in the Closed state.</span></div>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-success-100 bg-success-50 p-4 text-sm text-success-700"><span class="font-medium">✓ Success</span><span>Purchase order approved and sent for payment.</span></div>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-warning-100 bg-warning-50 p-4 text-sm text-warning-700"><span class="font-medium">⚠ Warning</span><span>This vendor is unverified — verify before assigning a vendor code.</span></div>
|
||||
<div class="flex items-start gap-3 rounded-lg border border-danger-100 bg-danger-50 p-4 text-sm text-danger-700"><span class="font-medium">✕ Error</span><span>Payment date cannot be in the future.</span></div>
|
||||
</div>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200 bg-neutral-100 p-8">
|
||||
<div class="mx-auto max-w-sm rounded-lg border border-neutral-200 bg-white p-6 shadow-lg">
|
||||
<h3 class="text-base font-semibold text-neutral-900">Delete accounting code?</h3>
|
||||
<p class="mt-1 text-sm text-neutral-500">This removes <b>5110 · Fuel & Lubricants</b>. This action can't be undone.</p>
|
||||
<div class="mt-5 flex justify-end gap-2">
|
||||
<button class="inline-flex h-10 items-center rounded-lg border border-neutral-300 bg-white px-4 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Cancel</button>
|
||||
<button class="inline-flex h-10 items-center rounded-lg bg-danger px-4 text-sm font-medium text-white hover:opacity-90">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
25
Wireframe/ds-bundle/components/badges.html
Normal file
25
Wireframe/ds-bundle/components/badges.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!-- @dsCard group="Components" name="Badges" subtitle="6 variants · rounded-full px-2.5 py-0.5 text-xs" width="700" height="160" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Badges</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{100:"#dbeafe",700:"#1d4ed8"},success:{100:"#dcfce7",700:"#15803d"},warning:{100:"#fef3c7",700:"#b45309"},danger:{100:"#fee2e2",700:"#b91c1c"},neutral:{100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",600:"#525252",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Badges</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">From <code>components/ui/badge.tsx</code>. Base: <code>rounded-full px-2.5 py-0.5 text-xs font-medium</code>.</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<span class="rounded-full bg-primary-100 px-2.5 py-0.5 text-xs font-medium text-primary-700">default</span>
|
||||
<span class="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">secondary</span>
|
||||
<span class="rounded-full bg-success-100 px-2.5 py-0.5 text-xs font-medium text-success-700">success</span>
|
||||
<span class="rounded-full bg-warning-100 px-2.5 py-0.5 text-xs font-medium text-warning-700">warning</span>
|
||||
<span class="rounded-full bg-danger-100 px-2.5 py-0.5 text-xs font-medium text-danger-700">danger</span>
|
||||
<span class="rounded-full border border-neutral-300 px-2.5 py-0.5 text-xs font-medium text-neutral-600">outline</span>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
36
Wireframe/ds-bundle/components/buttons.html
Normal file
36
Wireframe/ds-bundle/components/buttons.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<!-- @dsCard group="Components" name="Buttons" subtitle="7 variants × 4 sizes (CVA)" width="760" height="260" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Buttons</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{600:"#2563eb",700:"#1d4ed8"},success:{DEFAULT:"#16a34a"},warning:{DEFAULT:"#d97706",50:"#fffbeb",100:"#fef3c7",700:"#b45309"},danger:{DEFAULT:"#dc2626"},neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Buttons</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">From <code>components/ui/button.tsx</code> (CVA). Base: <code>inline-flex items-center gap-2 rounded-lg text-sm font-medium</code> + focus ring.</p>
|
||||
<div class="mt-4 space-y-4 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white hover:bg-primary-700">Default</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg border border-neutral-300 bg-white px-4 text-sm font-medium text-neutral-700 hover:bg-neutral-50">Secondary</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-success px-4 text-sm font-medium text-white hover:opacity-90">Success</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg border border-warning bg-warning-50 px-4 text-sm font-medium text-warning-700 hover:bg-warning-100">Warning</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-danger px-4 text-sm font-medium text-white hover:opacity-90">Destructive</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg px-4 text-sm font-medium text-neutral-700 hover:bg-neutral-100">Ghost</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg px-1 text-sm font-medium text-primary-600 underline-offset-4 hover:underline" style="color:#2563eb">Link</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white opacity-60" disabled>Disabled</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3 border-t border-neutral-100 pt-4">
|
||||
<button class="inline-flex h-8 items-center gap-1.5 rounded-lg bg-primary-600 px-3 text-xs font-medium text-white">sm · h-8</button>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white">md · h-10</button>
|
||||
<button class="inline-flex h-11 items-center gap-2 rounded-lg bg-primary-600 px-5 text-sm font-medium text-white">lg · h-11</button>
|
||||
<button class="inline-flex h-10 w-10 items-center justify-center rounded-lg border border-neutral-300 bg-white text-neutral-700"><svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg></button>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-400"><code>variant="default|secondary|success|warning|destructive|ghost|link"</code> · <code>size="sm|md|lg|icon"</code></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
30
Wireframe/ds-bundle/components/cards.html
Normal file
30
Wireframe/ds-bundle/components/cards.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!-- @dsCard group="Components" name="Cards & KPIs" subtitle="rounded-lg border bg-white shadow-sm · stat cards" width="780" height="340" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Cards</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{600:"#2563eb"},success:{700:"#15803d"},danger:{700:"#b91c1c"},neutral:{200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",600:"#525252",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif'],mono:['"JetBrains Mono"','ui-monospace','monospace']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}.mono{font-family:'JetBrains Mono',ui-monospace,monospace}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Cards & KPI stats</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Card = <code>rounded-lg border border-neutral-200 bg-white shadow-sm</code>. KPI stat cards drop the shadow, use <b>p-4</b>.</p>
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white shadow-sm">
|
||||
<div class="flex flex-col gap-1 p-6 pb-4"><h3 class="text-base font-semibold text-neutral-900">Vendor details</h3><p class="text-sm text-neutral-500">GST-verified supplier on file.</p></div>
|
||||
<div class="space-y-1 p-6 pt-0 text-sm text-neutral-600"><p>Acme Marine Supplies Pvt Ltd</p><p class="mono text-xs text-neutral-400">GSTIN 27ABCDE1234F1Z5</p></div>
|
||||
<div class="flex items-center gap-2 p-6 pt-0"><button class="inline-flex h-8 items-center rounded-lg bg-primary-600 px-3 text-xs font-medium text-white">Edit</button><button class="inline-flex h-8 items-center rounded-lg border border-neutral-300 bg-white px-3 text-xs font-medium text-neutral-700">View POs</button></div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Open POs</p><p class="mt-1.5 text-xl font-semibold">24</p><p class="mt-0.5 text-xs text-success-700">▲ 8% vs last month</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Awaiting approval</p><p class="mt-1.5 text-xl font-semibold">7</p><p class="mt-0.5 text-xs text-neutral-400">across 3 vessels</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Spend (FY)</p><p class="mt-1.5 text-xl font-semibold">₹2.4 Cr</p><p class="mt-0.5 text-xs text-neutral-400">FY 2025–26</p></div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-4"><p class="text-xs font-medium uppercase tracking-wider text-neutral-400">Overdue</p><p class="mt-1.5 text-xl font-semibold">2</p><p class="mt-0.5 text-xs text-danger-700">needs attention</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
35
Wireframe/ds-bundle/components/charts.html
Normal file
35
Wireframe/ds-bundle/components/charts.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<!-- @dsCard group="Components" name="Charts" subtitle="recharts conventions + 8-color series palette" width="780" height="420" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Charts</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{neutral:{100:"#f5f5f5",200:"#e5e5e5",500:"#737373",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif'],mono:['"JetBrains Mono"','ui-monospace','monospace']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}code{font-family:'JetBrains Mono',ui-monospace,monospace}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Charts</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-neutral-600"><b>recharts</b> inside a standard card. Grid <code>strokeDasharray="3 3"</code> in <code>#f0f0f0</code>; axis ticks <code>11px #737373</code>; bars <code>radius</code>, lines <code>strokeWidth 2</code>. Y-axis money via <code>formatCompactINR</code>.</p>
|
||||
<div class="mt-4 grid gap-4 lg:grid-cols-2">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-4 text-sm font-semibold">Spend by cost centre</p>
|
||||
<svg viewBox="0 0 320 170" class="w-full"><line x1="34" y1="10" x2="34" y2="140" stroke="#e5e5e5"/><line x1="34" y1="140" x2="320" y2="140" stroke="#e5e5e5"/><g><rect x="50" y="40" width="34" height="100" rx="3" fill="#2563eb"/><rect x="104" y="64" width="34" height="76" rx="3" fill="#2563eb"/><rect x="158" y="80" width="34" height="60" rx="3" fill="#2563eb"/><rect x="212" y="98" width="34" height="42" rx="3" fill="#2563eb"/><rect x="266" y="112" width="34" height="28" rx="3" fill="#2563eb"/></g><g font-size="9" fill="#737373"><text x="2" y="44">₹3Cr</text><text x="0" y="92">₹1.5Cr</text><text x="10" y="140">₹0</text></g></svg>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-4 text-sm font-semibold">Monthly trend</p>
|
||||
<svg viewBox="0 0 320 170" class="w-full"><line x1="34" y1="10" x2="34" y2="140" stroke="#e5e5e5"/><line x1="34" y1="140" x2="320" y2="140" stroke="#e5e5e5"/><polyline fill="none" stroke="#2563eb" stroke-width="2" points="46,70 92,52 138,96 184,60 230,108 276,46 308,72"/><polyline fill="none" stroke="#16a34a" stroke-width="2" points="46,110 92,100 138,118 184,88 230,124 276,96 308,104"/><g fill="#2563eb"><circle cx="92" cy="52" r="2.5"/><circle cx="276" cy="46" r="2.5"/></g></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-sm font-semibold">Multi-series palette</p>
|
||||
<div class="flex flex-wrap gap-2" id="p"></div>
|
||||
</div>
|
||||
<script>
|
||||
const C=["#2563eb","#16a34a","#9333ea","#ea580c","#0891b2","#dc2626","#ca8a04","#4f46e5"];
|
||||
document.getElementById("p").innerHTML=C.map(c=>`<div class="flex items-center gap-1.5 rounded-md border border-neutral-200 px-2 py-1"><span class="h-3.5 w-3.5 rounded" style="background:${c}"></span><code class="text-[10px] text-neutral-500">${c}</code></div>`).join("");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
27
Wireframe/ds-bundle/components/forms.html
Normal file
27
Wireframe/ds-bundle/components/forms.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!-- @dsCard group="Components" name="Forms" subtitle="inputs/select/textarea, focus ring-primary-500/20" width="680" height="440" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Forms</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{500:"#3b82f6"},danger:{700:"#b91c1c"},neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Forms</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">From <code>components/ui/input.tsx</code>: <code>h-10 rounded-lg border-neutral-300</code>; focus <code>border-primary-500 ring-2 ring-primary-500/20</code>.</p>
|
||||
<div class="mt-4 grid max-w-2xl gap-4 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">PO title</label><input placeholder="e.g. Engine spares — MV Pelagia Star" class="h-10 w-full rounded-lg border border-neutral-300 px-3 text-sm placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"/></div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Cost centre</label><select class="h-10 w-full rounded-lg border border-neutral-300 bg-white px-3 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"><option>MV Pelagia Star</option><option>MV Ocean Dawn</option></select></div>
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Date required</label><input type="date" class="h-10 w-full rounded-lg border border-neutral-300 px-3 text-sm text-neutral-700 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"/></div>
|
||||
</div>
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Notes</label><textarea rows="2" placeholder="Optional…" class="w-full rounded-lg border border-neutral-300 px-3 py-2 text-sm placeholder:text-neutral-400 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20"></textarea></div>
|
||||
<div><label class="mb-1.5 block text-sm font-medium text-neutral-700">Disabled</label><input disabled value="Read-only value" class="h-10 w-full cursor-not-allowed rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-sm text-neutral-500 opacity-60"/></div>
|
||||
<p class="-mt-1 text-xs text-danger-700">Inline validation error sits under the field in <code>text-danger-700</code>.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
23
Wireframe/ds-bundle/components/po-status.html
Normal file
23
Wireframe/ds-bundle/components/po-status.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<!-- @dsCard group="Components" name="PO status badges" subtitle="13 lifecycle states → badge variants (lib/utils.ts)" width="760" height="320" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · PO status</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{100:"#dbeafe",700:"#1d4ed8"},success:{100:"#dcfce7",700:"#15803d"},warning:{100:"#fef3c7",700:"#b45309"},danger:{100:"#fee2e2",700:"#b91c1c"},neutral:{100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",600:"#525252",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif'],mono:['"JetBrains Mono"','ui-monospace','monospace']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}code{font-family:'JetBrains Mono',ui-monospace,monospace}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">PO status badges</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-neutral-600">Lifecycle states map to badge variants in <code>lib/utils.ts</code> (<code>PO_STATUS_LABELS</code> / <code>PO_STATUS_VARIANTS</code>). Reuse this mapping — don't invent new status colors.</p>
|
||||
<div class="mt-4 grid gap-2 rounded-lg border border-neutral-200 bg-white p-5 sm:grid-cols-2 lg:grid-cols-3" id="g"></div>
|
||||
<script>
|
||||
const V={default:"bg-primary-100 text-primary-700",secondary:"bg-neutral-100 text-neutral-700",success:"bg-success-100 text-success-700",warning:"bg-warning-100 text-warning-700",danger:"bg-danger-100 text-danger-700",outline:"border border-neutral-300 text-neutral-600"};
|
||||
const S=[["DRAFT","Draft","outline"],["SUBMITTED","Submitted","secondary"],["MGR_REVIEW","Under Review","secondary"],["VENDOR_ID_PENDING","Vendor ID Pending","warning"],["EDITS_REQUESTED","Edits Requested","warning"],["REJECTED","Rejected","danger"],["MGR_APPROVED","Approved","success"],["SENT_FOR_PAYMENT","Sent for Payment","default"],["PARTIALLY_PAID","Partially Paid","warning"],["PAID_DELIVERED","Paid","success"],["PARTIALLY_CLOSED","Partially Received","warning"],["CLOSED","Closed","secondary"],["CANCELLED","Cancelled","danger"]];
|
||||
document.getElementById("g").innerHTML=S.map(([k,l,v])=>`<div class="flex items-center justify-between gap-2 rounded-md px-2 py-1.5"><span class="rounded-full px-2.5 py-0.5 text-xs font-medium ${V[v]}">${l}</span><code class="text-[10px] text-neutral-400">${k}</code></div>`).join("");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
29
Wireframe/ds-bundle/components/tables.html
Normal file
29
Wireframe/ds-bundle/components/tables.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!-- @dsCard group="Components" name="Tables" subtitle="neutral-50 header, divide-y rows, tabular-nums money" width="760" height="280" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Tables</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{success:{100:"#dcfce7",700:"#15803d"},warning:{100:"#fef3c7",700:"#b45309"},neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",400:"#a3a3a3",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif'],mono:['"JetBrains Mono"','ui-monospace','monospace']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}.mono{font-family:'JetBrains Mono',ui-monospace,monospace}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Tables</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Header <code>bg-neutral-50</code> + <code>text-xs uppercase tracking-wider text-neutral-400</code>; rows <code>divide-y divide-neutral-100</code>, hover <code>hover:bg-neutral-50</code>; money <code>tabular-nums text-right</code>.</p>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200 bg-white">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="border-b border-neutral-200 bg-neutral-50 text-left text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||
<tr><th class="px-5 py-3">PO Number</th><th class="px-5 py-3">Vessel</th><th class="px-5 py-3">Status</th><th class="px-5 py-3 text-right">Amount</th></tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-neutral-100">
|
||||
<tr class="hover:bg-neutral-50"><td class="px-5 py-3 mono text-xs">PMS/HNR1/9001/2025-26</td><td class="px-5 py-3">MV Pelagia Star</td><td class="px-5 py-3"><span class="rounded-full bg-success-100 px-2.5 py-0.5 text-xs font-medium text-success-700">Approved</span></td><td class="px-5 py-3 text-right tabular-nums">₹4,82,000</td></tr>
|
||||
<tr class="hover:bg-neutral-50"><td class="px-5 py-3 mono text-xs">PMS/OCD2/9002/2025-26</td><td class="px-5 py-3">MV Ocean Dawn</td><td class="px-5 py-3"><span class="rounded-full bg-warning-100 px-2.5 py-0.5 text-xs font-medium text-warning-700">Edits Requested</span></td><td class="px-5 py-3 text-right tabular-nums">₹1,15,500</td></tr>
|
||||
<tr class="hover:bg-neutral-50"><td class="px-5 py-3 mono text-xs">PMS/CRT3/9003/2025-26</td><td class="px-5 py-3">MV Coral Trident</td><td class="px-5 py-3"><span class="rounded-full bg-neutral-100 px-2.5 py-0.5 text-xs font-medium text-neutral-700">Closed</span></td><td class="px-5 py-3 text-right tabular-nums">₹92,300</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
29
Wireframe/ds-bundle/components/tabs.html
Normal file
29
Wireframe/ds-bundle/components/tabs.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<!-- @dsCard group="Components" name="Tabs & segmented" subtitle="pill segmented control + underline tab bar" width="700" height="180" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Tabs</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{600:"#2563eb",700:"#1d4ed8"},neutral:{200:"#e5e5e5",500:"#737373",800:"#262626",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Tabs & segmented controls</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600">Toolbar filters use a pill segmented control; page sections use an underline tab bar.</p>
|
||||
<div class="mt-4 flex flex-wrap items-center gap-6 rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<div class="inline-flex rounded-lg border border-neutral-200 bg-white p-0.5 text-sm">
|
||||
<button class="rounded-md bg-primary-600 px-3 py-1 font-medium text-white shadow-sm">Monthly</button>
|
||||
<button class="rounded-md px-3 py-1 font-medium text-neutral-500 hover:text-neutral-800">Yearly</button>
|
||||
<button class="rounded-md px-3 py-1 font-medium text-neutral-500 hover:text-neutral-800">Weekly</button>
|
||||
</div>
|
||||
<div class="flex items-center gap-6 border-b border-neutral-200 text-sm">
|
||||
<button class="-mb-px border-b-2 border-primary-600 pb-2 font-medium text-primary-700">Details</button>
|
||||
<button class="-mb-px border-b-2 border-transparent pb-2 font-medium text-neutral-500 hover:text-neutral-800">Line items</button>
|
||||
<button class="-mb-px border-b-2 border-transparent pb-2 font-medium text-neutral-500 hover:text-neutral-800">History</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
33
Wireframe/ds-bundle/foundations/colors.html
Normal file
33
Wireframe/ds-bundle/foundations/colors.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!-- @dsCard group="Foundations" name="Color ramps" subtitle="Primary · Success · Warning · Danger · Neutral" width="780" height="560" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Color</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}.mono{font-family:'JetBrains Mono',ui-monospace,monospace}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6" style="color:#171717">
|
||||
<h2 class="text-lg font-semibold">Color</h2>
|
||||
<p class="mt-1 mb-5 max-w-2xl text-sm" style="color:#525252">Five ramps. <b>Primary</b> = actions/links/active nav · <b>Success</b> = approved/paid · <b>Warning</b> = needs action · <b>Danger</b> = rejected/destructive · <b>Neutral</b> = structure, text, borders.</p>
|
||||
<div class="space-y-5" id="r"></div>
|
||||
<script>
|
||||
const RAMPS=[
|
||||
["Primary","primary",[["50","#eff6ff"],["100","#dbeafe"],["200","#bfdbfe"],["500","#3b82f6"],["600","#2563eb"],["700","#1d4ed8"],["800","#1e40af"]]],
|
||||
["Success","success",[["50","#f0fdf4"],["100","#dcfce7"],["DEFAULT","#16a34a"],["700","#15803d"]]],
|
||||
["Warning","warning",[["50","#fffbeb"],["100","#fef3c7"],["DEFAULT","#d97706"],["700","#b45309"]]],
|
||||
["Danger","danger",[["50","#fef2f2"],["100","#fee2e2"],["DEFAULT","#dc2626"],["700","#b91c1c"]]],
|
||||
["Neutral","neutral",[["50","#fafafa"],["100","#f5f5f5"],["200","#e5e5e5"],["300","#d4d4d4"],["400","#a3a3a3"],["500","#737373"],["600","#525252"],["700","#404040"],["800","#262626"],["900","#171717"]]],
|
||||
];
|
||||
document.getElementById("r").innerHTML=RAMPS.map(([name,key,stops])=>`
|
||||
<div>
|
||||
<div class="mb-1.5 flex items-baseline gap-2"><span class="text-sm font-semibold">${name}</span><code class="mono text-xs" style="color:#a3a3a3">--color-${key}-*</code></div>
|
||||
<div class="grid grid-cols-4 gap-2 sm:grid-cols-7 lg:grid-cols-10">
|
||||
${stops.map(([s,hex])=>{const cls=s==="DEFAULT"?key:`${key}-${s}`;return `<div class="overflow-hidden rounded-lg" style="border:1px solid #e5e5e5"><div class="h-12" style="background:${hex}"></div><div class="bg-white px-1.5 py-1"><p class="text-[11px] font-medium">${s}</p><p class="mono text-[10px]" style="color:#a3a3a3">${hex}</p><p class="mono text-[9px]" style="color:#a3a3a3">${cls}</p></div></div>`;}).join("")}
|
||||
</div>
|
||||
</div>`).join("");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
22
Wireframe/ds-bundle/foundations/icons.html
Normal file
22
Wireframe/ds-bundle/foundations/icons.html
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<!-- @dsCard group="Foundations" name="Icons" subtitle="lucide-react, stroke 2, h-4/h-5, currentColor" width="640" height="200" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Icons</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",400:"#a3a3a3",600:"#525252",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Icons</h2>
|
||||
<p class="mt-1 text-sm text-neutral-600"><b>lucide-react</b>, stroke 2, sized <code>h-4 w-4</code> inline / <code>h-5 w-5</code> standalone. They inherit <code>currentColor</code>.</p>
|
||||
<div class="mt-4 flex flex-wrap gap-3 rounded-lg border border-neutral-200 bg-white p-5 text-neutral-600" id="i"></div>
|
||||
<script>
|
||||
const I={dashboard:'<rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/>',file:'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/>',plus:'<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',check:'<polyline points="20 6 9 17 4 12"/>',ship:'<path d="M12 22V8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/>',users:'<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/>',card:'<rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/>',download:'<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',search:'<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',bell:'<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>',chart:'<path d="M3 3v18h18"/><rect x="7" y="9" width="3" height="9"/><rect x="14" y="5" width="3" height="13"/>'};
|
||||
document.getElementById("i").innerHTML=Object.entries(I).map(([n,p])=>`<div class="flex w-16 flex-col items-center gap-1"><svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${p}</svg><span class="text-[10px] text-neutral-400">${n}</span></div>`).join("");
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
46
Wireframe/ds-bundle/foundations/tokens.html
Normal file
46
Wireframe/ds-bundle/foundations/tokens.html
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!-- @dsCard group="Foundations" name="Radius · Shadow · Spacing" subtitle="rounded-lg surfaces, 1px borders, shadow-sm" width="780" height="280" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Tokens</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{200:"#bfdbfe"},neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",500:"#737373",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif'],mono:['"JetBrains Mono"','ui-monospace','monospace']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}code{font-family:'JetBrains Mono',ui-monospace,monospace}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Radius · Shadow · Spacing</h2>
|
||||
<div class="mt-4 grid gap-4 sm:grid-cols-3">
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Radius</p>
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-md border border-neutral-300 bg-neutral-50"></div><code class="mt-1 block text-[11px] text-neutral-400">rounded-md</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg border border-neutral-300 bg-neutral-50"></div><code class="mt-1 block text-[11px] text-neutral-400">rounded-lg</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-full border border-neutral-300 bg-neutral-50"></div><code class="mt-1 block text-[11px] text-neutral-400">rounded-full</code></div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-neutral-500"><b>lg</b> for cards/buttons/inputs, <b>md</b> for nav items, <b>full</b> for badges & avatars.</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Elevation</p>
|
||||
<div class="flex items-end gap-4">
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg border border-neutral-200 bg-white"></div><code class="mt-1 block text-[11px] text-neutral-400">border</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg border border-neutral-200 bg-white shadow-sm"></div><code class="mt-1 block text-[11px] text-neutral-400">shadow-sm</code></div>
|
||||
<div class="text-center"><div class="h-12 w-12 rounded-lg bg-white shadow-lg"></div><code class="mt-1 block text-[11px] text-neutral-400">shadow-lg</code></div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-neutral-500">Flat by default — a 1px <b>border-neutral-200</b> separates surfaces. <b>shadow-sm</b> on cards, <b>shadow-lg</b> only for popovers/dialogs.</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-neutral-200 bg-white p-5">
|
||||
<p class="mb-3 text-xs font-semibold uppercase tracking-wider text-neutral-400">Spacing rhythm</p>
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:16px"></div><code class="text-[11px] text-neutral-400">gap-2 · 8px</code></div>
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:24px"></div><code class="text-[11px] text-neutral-400">gap-3 · 12px (rows)</code></div>
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:40px"></div><code class="text-[11px] text-neutral-400">p-5 · 20px (cards)</code></div>
|
||||
<div class="flex items-center gap-2"><div class="h-3 bg-primary-200" style="width:48px"></div><code class="text-[11px] text-neutral-400">p-6 / gap-6 · 24px</code></div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-neutral-500">Page padding <b>p-6</b>; cards <b>p-5</b>/<b>p-6</b>; control gaps <b>gap-2/3</b>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
26
Wireframe/ds-bundle/foundations/typography.html
Normal file
26
Wireframe/ds-bundle/foundations/typography.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!-- @dsCard group="Foundations" name="Typography" subtitle="Inter (UI) · JetBrains Mono (codes/numbers)" width="760" height="420" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Typography</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{neutral:{100:"#f5f5f5",200:"#e5e5e5",400:"#a3a3a3",500:"#737373",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif'],mono:['"JetBrains Mono"','ui-monospace','monospace']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}.mono,code{font-family:'JetBrains Mono',ui-monospace,monospace}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Typography</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500"><b>Inter</b> for UI, <b>JetBrains Mono</b> for codes & numbers (PO numbers, accounting codes).</p>
|
||||
<div class="mt-4 divide-y divide-neutral-200 rounded-lg border border-neutral-200 bg-white">
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-2xl font-semibold text-neutral-900">Page title</p><code class="text-xs text-neutral-400">text-2xl font-semibold</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-lg font-semibold text-neutral-900">Section heading</p><code class="text-xs text-neutral-400">text-lg font-semibold</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-base font-semibold text-neutral-900">Card title</p><code class="text-xs text-neutral-400">text-base font-semibold</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-sm text-neutral-700">Body / table text — the workhorse size for the whole app.</p><code class="text-xs text-neutral-400">text-sm</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-sm text-neutral-500">Muted description & helper text</p><code class="text-xs text-neutral-400">text-sm text-neutral-500</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="text-xs font-semibold uppercase tracking-wider text-neutral-400">Eyebrow / label</p><code class="text-xs text-neutral-400">text-xs uppercase tracking-wider</code></div>
|
||||
<div class="flex items-baseline justify-between gap-4 p-4"><p class="mono text-sm text-neutral-700">PMS/HNR1/9000/2024-25</p><code class="text-xs text-neutral-400">font-mono</code></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
48
Wireframe/ds-bundle/layout/app-shell.html
Normal file
48
Wireframe/ds-bundle/layout/app-shell.html
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<!-- @dsCard group="Layout" name="App shell" subtitle="w-60 sidebar + h-16 top bar + bg-neutral-50 main" width="780" height="380" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · App shell</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{50:"#eff6ff",600:"#2563eb",700:"#1d4ed8"},neutral:{50:"#fafafa",100:"#f5f5f5",200:"#e5e5e5",300:"#d4d4d4",400:"#a3a3a3",600:"#525252",700:"#404040",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">App shell</h2>
|
||||
<p class="mt-1 max-w-2xl text-sm text-neutral-600">Fixed <b>w-60</b> white sidebar (<code>border-r</code>) + <b>h-16</b> top bar + scrollable <code>main</code> on <code>bg-neutral-50</code>, <b>p-6</b>. Active nav = <code>bg-primary-50 text-primary-700</code>; section eyebrow = <code>text-xs uppercase tracking-wider text-neutral-400</code>.</p>
|
||||
<div class="mt-4 overflow-hidden rounded-lg border border-neutral-200">
|
||||
<div class="flex h-72 bg-neutral-50">
|
||||
<aside class="flex w-56 shrink-0 flex-col border-r border-neutral-200 bg-white">
|
||||
<div class="flex h-14 items-center gap-2.5 border-b border-neutral-200 px-4">
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-primary-600"><svg class="h-4 w-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="12" y1="22" x2="12" y2="8"/><path d="M5 12H2a10 10 0 0 0 20 0h-3"/><circle cx="12" cy="5" r="3"/></svg></div>
|
||||
<span class="text-sm font-semibold">PPMS</span>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-0.5 p-3 text-sm">
|
||||
<a class="flex items-center gap-3 rounded-md bg-primary-50 px-3 py-2 font-medium text-primary-700"><span class="h-4 w-4 rounded" style="background:rgba(37,99,235,.2)"></span>Dashboard</a>
|
||||
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100"><span class="h-4 w-4 rounded bg-neutral-300"></span>New PO</a>
|
||||
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100"><span class="h-4 w-4 rounded bg-neutral-300"></span>Approvals</a>
|
||||
<p class="px-3 pb-1 pt-4 text-xs font-semibold uppercase tracking-wider text-neutral-400">Administration</p>
|
||||
<a class="flex items-center gap-3 rounded-md px-3 py-2 font-medium text-neutral-600 hover:bg-neutral-100"><span class="h-4 w-4 rounded bg-neutral-300"></span>Users</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<div class="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-5">
|
||||
<span class="text-sm font-medium text-neutral-500">Dashboard</span>
|
||||
<span class="h-7 w-7 rounded-full bg-neutral-200"></span>
|
||||
</div>
|
||||
<div class="flex-1 space-y-3 overflow-hidden p-5">
|
||||
<div class="h-4 w-40 rounded bg-neutral-200"></div>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="h-16 rounded-lg border border-neutral-200 bg-white"></div>
|
||||
<div class="h-16 rounded-lg border border-neutral-200 bg-white"></div>
|
||||
<div class="h-16 rounded-lg border border-neutral-200 bg-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
28
Wireframe/ds-bundle/layout/page-header.html
Normal file
28
Wireframe/ds-bundle/layout/page-header.html
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!-- @dsCard group="Layout" name="Page header" subtitle="Title + muted subtitle, primary action right" width="760" height="180" -->
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>PPMS · Page header</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script>tailwind.config={theme:{extend:{colors:{primary:{600:"#2563eb",700:"#1d4ed8"},neutral:{200:"#e5e5e5",500:"#737373",900:"#171717"}},fontFamily:{sans:['Inter','ui-sans-serif','system-ui','sans-serif']}}}}</script>
|
||||
<style>body{font-family:'Inter',ui-sans-serif,system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-neutral-50 p-6 text-neutral-900">
|
||||
<h2 class="text-lg font-semibold">Page header</h2>
|
||||
<p class="mt-1 text-sm text-neutral-500">Title + muted subtitle on the left, one primary action on the right.</p>
|
||||
<div class="mt-4 rounded-lg border border-neutral-200 bg-white p-6">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-neutral-900">Purchase Orders</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500">Create, track and approve orders across the fleet.</p>
|
||||
</div>
|
||||
<button class="inline-flex h-10 items-center gap-2 rounded-lg bg-primary-600 px-4 text-sm font-medium text-white hover:bg-primary-700">
|
||||
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>New PO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -161,16 +161,22 @@ portal ──(triage)──▶ triaged + claude-queue ─▶ claude-working ─
|
|||
|
||||
## Releasing
|
||||
|
||||
After merging a Claude PR (or any change) on `master`:
|
||||
> ⚠️ **Release tags MUST be `v`-prefixed** (e.g. `v0.2.2`). `deploy.yml` triggers only on
|
||||
> `v*` tags — a bare tag like `0.2.2` will **NOT** deploy (the runner ignores it and prod
|
||||
> stays on the previous version). Push the **tag** specifically; pushing `master` alone
|
||||
> never deploys.
|
||||
|
||||
After merging PR(s) on `master`:
|
||||
|
||||
```powershell
|
||||
git pull
|
||||
git tag v0.2.0 # semver: bump patch for fixes, minor for features
|
||||
git push pms1 master --tags
|
||||
git tag v0.2.2 # MUST start with "v"; semver: patch = fixes, minor = features
|
||||
git push pms1 v0.2.2 # pushing the v* tag is what triggers the deploy
|
||||
```
|
||||
|
||||
The runner deploys the tag and restarts the app. Watch progress under
|
||||
**Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||
The runner checks out the tag in `~/pms`, runs `pnpm install` + `build` +
|
||||
`prisma migrate deploy`, `pm2 restart ppms`, and verifies `/login` returns 200. Watch
|
||||
progress under **Actions** on the Forgejo repo, or `pm2 logs forgejo-runner` on pms1.
|
||||
|
||||
## Operational notes
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue