chore(inventory): remove item detail page; move SiteSelect to shared components
- Delete /inventory/items/[id] — items expand inline in the list - Move SiteSelect from deleted [id] folder to components/inventory/site-select - Fix admin product detail page import to use new shared path - Fix items-table: Fragment key prop, restore Link import, plain text item names - Fix vendor-items-table: remove broken link to deleted item detail page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
bfdf5e73eb
commit
d769cae71e
36 changed files with 10191 additions and 230 deletions
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"WebFetch(domain:developer.gst.gov.in)",
|
||||||
|
"Bash(curl -s \"https://static.gst.gov.in/uiassets/js/services/forouter1.8.js\")",
|
||||||
|
"Bash(curl)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,7 +8,7 @@ import { distanceKm, formatDistance } from "@/lib/geo";
|
||||||
import { ToggleProductButton } from "../product-form";
|
import { ToggleProductButton } from "../product-form";
|
||||||
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
||||||
import { ItemPriceChart } from "./item-price-chart";
|
import { ItemPriceChart } from "./item-price-chart";
|
||||||
import { SiteSelect } from "@/app/(portal)/inventory/items/[id]/site-select";
|
import { SiteSelect } from "@/components/inventory/site-select";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { db } from "@/lib/db";
|
|
||||||
import { notFound, redirect } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
|
||||||
import { distanceKm, formatDistance } from "@/lib/geo";
|
|
||||||
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
|
||||||
import { ItemPriceChart } from "@/app/(portal)/admin/products/[id]/item-price-chart";
|
|
||||||
import { SiteSelect } from "./site-select";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
params: Promise<{ id: string }>;
|
|
||||||
searchParams: Promise<{ siteId?: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
||||||
const { id } = await params;
|
|
||||||
const product = await db.product.findUnique({ where: { id }, select: { name: true } });
|
|
||||||
return { title: product?.name ?? "Item Detail" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function InventoryItemDetailPage({ params, searchParams }: Props) {
|
|
||||||
const session = await auth();
|
|
||||||
if (!session?.user) redirect("/login");
|
|
||||||
|
|
||||||
const { id } = await params;
|
|
||||||
const { siteId } = await searchParams;
|
|
||||||
|
|
||||||
const [product, sites] = await Promise.all([
|
|
||||||
db.product.findUnique({
|
|
||||||
where: { id, isActive: true },
|
|
||||||
include: {
|
|
||||||
vendorPrices: {
|
|
||||||
where: { vendor: { isActive: true } },
|
|
||||||
include: {
|
|
||||||
vendor: {
|
|
||||||
select: { id: true, name: true, isVerified: true, isActive: true, latitude: true, longitude: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: { price: "asc" },
|
|
||||||
},
|
|
||||||
inventory: { include: { site: { select: { id: true, name: true, code: true } } } },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
db.site.findMany({
|
|
||||||
where: { isActive: true, latitude: { not: null }, longitude: { not: null } },
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
select: { id: true, name: true, latitude: true, longitude: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!product) notFound();
|
|
||||||
|
|
||||||
const selectedSite = siteId ? (sites.find((s) => s.id === siteId) ?? null) : null;
|
|
||||||
|
|
||||||
const enriched = product.vendorPrices.map((vp) => {
|
|
||||||
let dist: number | null = null;
|
|
||||||
if (selectedSite?.latitude && selectedSite.longitude && vp.vendor.latitude && vp.vendor.longitude) {
|
|
||||||
dist = distanceKm(selectedSite.latitude, selectedSite.longitude, vp.vendor.latitude, vp.vendor.longitude);
|
|
||||||
}
|
|
||||||
return { ...vp, distanceKm: dist, price: Number(vp.price) };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (selectedSite) {
|
|
||||||
enriched.sort((a, b) => {
|
|
||||||
if (a.distanceKm !== null && b.distanceKm !== null) return a.distanceKm - b.distanceKm;
|
|
||||||
if (a.distanceKm !== null) return -1;
|
|
||||||
if (b.distanceKm !== null) return 1;
|
|
||||||
return a.price - b.price;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const prices = enriched.map((v) => v.price);
|
|
||||||
const minPrice = prices.length > 0 ? Math.min(...prices) : null;
|
|
||||||
const maxPrice = prices.length > 0 ? Math.max(...prices) : null;
|
|
||||||
|
|
||||||
const priceChartData = enriched.map((vp) => ({
|
|
||||||
vendor: vp.vendor.name.length > 16 ? vp.vendor.name.slice(0, 14) + "…" : vp.vendor.name,
|
|
||||||
price: vp.price,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const baseHref = `/inventory/items/${id}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl space-y-6">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<div className="flex items-center gap-2 text-sm text-neutral-500">
|
|
||||||
<Link href="/inventory/items" className="hover:text-neutral-700">Items</Link>
|
|
||||||
<span>/</span>
|
|
||||||
<span className="text-neutral-900 font-medium">{product.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-3 mb-1">
|
|
||||||
<span className="font-mono text-xs text-neutral-500">{product.code}</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-semibold text-neutral-900">{product.name}</h1>
|
|
||||||
{product.description && <p className="mt-1 text-sm text-neutral-500">{product.description}</p>}
|
|
||||||
</div>
|
|
||||||
{minPrice !== null && (
|
|
||||||
<AddToCartButton
|
|
||||||
item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: minPrice }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
|
||||||
<p className="text-xs text-neutral-500 mb-1">Vendors</p>
|
|
||||||
<p className="text-2xl font-semibold text-neutral-900">{product.vendorPrices.length}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
|
||||||
<p className="text-xs text-neutral-500 mb-1">Lowest Price</p>
|
|
||||||
<p className="text-2xl font-semibold text-success-700">{minPrice !== null ? formatCurrency(minPrice) : "—"}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white px-5 py-4">
|
|
||||||
<p className="text-xs text-neutral-500 mb-1">Highest Price</p>
|
|
||||||
<p className="text-2xl font-semibold text-neutral-900">{maxPrice !== null ? formatCurrency(maxPrice) : "—"}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price chart */}
|
|
||||||
{priceChartData.length > 1 && <ItemPriceChart data={priceChartData} />}
|
|
||||||
|
|
||||||
{/* Site filter */}
|
|
||||||
{sites.length > 0 && (
|
|
||||||
<SiteSelect
|
|
||||||
sites={sites.map((s) => ({ id: s.id, name: s.name }))}
|
|
||||||
currentSiteId={siteId ?? null}
|
|
||||||
baseHref={baseHref}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Vendors table */}
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
||||||
<h2 className="text-sm font-semibold text-neutral-900 mb-4">
|
|
||||||
Available From
|
|
||||||
<span className="ml-2 text-neutral-400 font-normal">({enriched.length} vendor{enriched.length !== 1 ? "s" : ""})</span>
|
|
||||||
{selectedSite && <span className="ml-2 text-primary-600 font-normal text-xs">sorted by distance from {selectedSite.name}</span>}
|
|
||||||
</h2>
|
|
||||||
{enriched.length === 0 ? (
|
|
||||||
<p className="text-sm text-neutral-400 italic">No vendor pricing on record. Updated automatically when a PO is marked as paid.</p>
|
|
||||||
) : (
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-neutral-200">
|
|
||||||
<th className="pb-2 text-left font-medium text-neutral-600">Vendor</th>
|
|
||||||
<th className="pb-2 text-left font-medium text-neutral-600 pl-4">Verified</th>
|
|
||||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Price</th>
|
|
||||||
{selectedSite && <th className="pb-2 text-right font-medium text-neutral-600 pl-4">Distance</th>}
|
|
||||||
<th className="pb-2 text-right font-medium text-neutral-600 pl-4">Updated</th>
|
|
||||||
<th className="pb-2 pl-4" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-neutral-100">
|
|
||||||
{enriched.map((vp, idx) => {
|
|
||||||
const isCheapest = minPrice !== null && vp.price === minPrice && enriched.length > 1;
|
|
||||||
const isClosest = selectedSite !== null && idx === 0 && vp.distanceKm !== null;
|
|
||||||
return (
|
|
||||||
<tr key={vp.id} className="hover:bg-neutral-50">
|
|
||||||
<td className="py-2.5 pr-4">
|
|
||||||
<Link href={`/inventory/vendors/${vp.vendor.id}`} className="font-medium text-primary-600 hover:underline">
|
|
||||||
{vp.vendor.name}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5 pl-4">
|
|
||||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${vp.vendor.isVerified ? "bg-success-100 text-success-700" : "bg-warning-100 text-warning-700"}`}>
|
|
||||||
{vp.vendor.isVerified ? "Verified" : "Unverified"}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="py-2.5 pl-4 text-right">
|
|
||||||
<span className={`font-semibold ${isCheapest ? "text-success-700" : "text-neutral-900"}`}>{formatCurrency(vp.price)}</span>
|
|
||||||
{isCheapest && !selectedSite && <span className="ml-1.5 text-xs text-success-600">lowest</span>}
|
|
||||||
</td>
|
|
||||||
{selectedSite && (
|
|
||||||
<td className="py-2.5 pl-4 text-right">
|
|
||||||
{vp.distanceKm !== null
|
|
||||||
? <span className={isClosest ? "font-semibold text-primary-700" : "text-neutral-600"}>{formatDistance(vp.distanceKm)}{isClosest ? " ★" : ""}</span>
|
|
||||||
: <span className="text-neutral-400 italic text-xs">No location</span>}
|
|
||||||
</td>
|
|
||||||
)}
|
|
||||||
<td className="py-2.5 pl-4 text-right text-neutral-500">{formatDate(vp.updatedAt)}</td>
|
|
||||||
<td className="py-2.5 pl-4">
|
|
||||||
<AddToCartButton
|
|
||||||
item={{ productId: product.id, name: product.name, description: product.description ?? undefined, unit: "pc", unitPrice: vp.price, vendorId: vp.vendor.id, vendorName: vp.vendor.name }}
|
|
||||||
className="text-xs text-primary-600 hover:underline font-medium whitespace-nowrap"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Stock by site */}
|
|
||||||
{product.inventory.length > 0 && (
|
|
||||||
<div className="rounded-lg border border-neutral-200 bg-white p-6">
|
|
||||||
<h2 className="text-sm font-semibold text-neutral-900 mb-4">Stock on Hand</h2>
|
|
||||||
<div className="flex flex-wrap gap-3">
|
|
||||||
{product.inventory.map((inv) => (
|
|
||||||
<div key={inv.id} className="rounded-lg border border-neutral-200 px-4 py-2 text-sm">
|
|
||||||
<span className="font-medium text-neutral-900">{inv.site.name}</span>
|
|
||||||
<span className="ml-1 text-xs text-neutral-500">({inv.site.code})</span>
|
|
||||||
<span className="ml-2 text-neutral-600 font-semibold">{Number(inv.quantity)} units</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -199,13 +199,7 @@ export function ItemsTable({
|
||||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<Link
|
<span className="font-medium text-neutral-900">{item.name}</span>
|
||||||
href={`/inventory/items/${item.id}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="font-medium text-neutral-900 hover:text-primary-600 hover:underline"
|
|
||||||
>
|
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
|
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
|
||||||
import { formatCurrency, formatDate } from "@/lib/utils";
|
import { formatCurrency, formatDate } from "@/lib/utils";
|
||||||
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
import { AddToCartButton } from "@/components/inventory/add-to-cart-button";
|
||||||
|
|
||||||
|
|
@ -75,9 +74,7 @@ export function VendorItemsTable({ items }: { items: Item[] }) {
|
||||||
{filtered.map((item) => (
|
{filtered.map((item) => (
|
||||||
<tr key={item.id} className="hover:bg-neutral-50">
|
<tr key={item.id} className="hover:bg-neutral-50">
|
||||||
<td className="py-2.5 pr-4">
|
<td className="py-2.5 pr-4">
|
||||||
<Link href={`/inventory/items/${item.productId}`} className="font-medium text-primary-600 hover:underline">
|
<span className="font-medium text-neutral-900">{item.name}</span>
|
||||||
{item.name}
|
|
||||||
</Link>
|
|
||||||
{item.description && (
|
{item.description && (
|
||||||
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
|
<span className="block text-xs text-neutral-500 mt-0.5 line-clamp-1">{item.description}</span>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
719
DESIGN.md
Normal file
719
DESIGN.md
Normal file
|
|
@ -0,0 +1,719 @@
|
||||||
|
# Pelagia Portal — Design Document
|
||||||
|
|
||||||
|
Internal purchase-order management system for a maritime company.
|
||||||
|
This document describes every feature, page, workflow, and user story to guide UI/UX design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Pelagia Portal digitises the full purchase-order lifecycle — from a crew member raising a requisition aboard a vessel, through manager approval and payment by accounts, to receipt confirmation on delivery. It replaces paper and email-based processes with a traceable, role-gated workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Roles
|
||||||
|
|
||||||
|
Seven roles exist. Each role represents a real job function in the company.
|
||||||
|
|
||||||
|
| Role | Who they are | Core capability |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| **TECHNICAL** | Ship technical crew | Create, submit, and track their own POs; confirm delivery |
|
||||||
|
| **MANNING** | Manning crew | Same as TECHNICAL |
|
||||||
|
| **ACCOUNTS** | Finance / accounts team | Process payments, manage vendor registry |
|
||||||
|
| **MANAGER** | Department manager | Review and approve POs, edit line items before approval, view analytics |
|
||||||
|
| **SUPERUSER** | Power user / ops lead | All PO actions across the board |
|
||||||
|
| **AUDITOR** | Internal auditor | Read-only view of all POs; export reports |
|
||||||
|
| **ADMIN** | System administrator | Manage users, vendors, vessels, accounts, products, and sites |
|
||||||
|
|
||||||
|
### Role Access Matrix
|
||||||
|
|
||||||
|
| Feature area | TECH / MANNING | ACCOUNTS | MANAGER | SUPERUSER | AUDITOR | ADMIN |
|
||||||
|
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| Create / edit own POs | ✓ | | ✓ | ✓ | | |
|
||||||
|
| Approve / reject POs | | | ✓ | ✓ | | |
|
||||||
|
| Process payments | | ✓ | | ✓ | | |
|
||||||
|
| Confirm receipt | ✓ | | | ✓ | | |
|
||||||
|
| View all POs | | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| View analytics / export | | | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Vendor registry | | ✓ | ✓ | | | ✓ |
|
||||||
|
| Item catalogue | | | ✓ | | | ✓ |
|
||||||
|
| Vessel management | | | ✓ | | | ✓ |
|
||||||
|
| Site management | | | ✓ | | | ✓ |
|
||||||
|
| User management | | | | | | ✓ |
|
||||||
|
| Account management | | | ✓ | | | ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Navigation Structure
|
||||||
|
|
||||||
|
The left sidebar adapts to the signed-in user's role.
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard ← all users
|
||||||
|
|
||||||
|
─── Purchase Orders ──────────────────
|
||||||
|
New PO ← TECH, MANNING, MANAGER, SUPERUSER
|
||||||
|
My Orders ← TECH, MANNING, MANAGER, SUPERUSER
|
||||||
|
Approvals ← MANAGER, SUPERUSER
|
||||||
|
Import PO ← MANAGER, SUPERUSER, ADMIN
|
||||||
|
Payments ← ACCOUNTS
|
||||||
|
History / Export ← MANAGER, SUPERUSER, ACCOUNTS, AUDITOR, ADMIN
|
||||||
|
|
||||||
|
─── Inventory ───────────────────────
|
||||||
|
Vendors ← MANAGER, ACCOUNTS, ADMIN
|
||||||
|
Items ← MANAGER, ADMIN
|
||||||
|
Vessels ← MANAGER, ADMIN
|
||||||
|
Sites ← MANAGER, ADMIN
|
||||||
|
Cart ← TECH, MANNING, MANAGER, SUPERUSER
|
||||||
|
|
||||||
|
─── Administration ────────────────── (ADMIN only)
|
||||||
|
Users
|
||||||
|
Accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication
|
||||||
|
|
||||||
|
### Login Page `/login`
|
||||||
|
|
||||||
|
- Email + password form
|
||||||
|
- Validates credentials against bcrypt hash
|
||||||
|
- On success: redirects to `/dashboard` (or pre-login destination)
|
||||||
|
- No self-registration; accounts are created by an ADMIN
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Page Catalogue
|
||||||
|
|
||||||
|
### 5.1 Dashboard `/dashboard`
|
||||||
|
|
||||||
|
Entry point after login. Content varies by role.
|
||||||
|
|
||||||
|
**Submitter view (TECHNICAL / MANNING / SUPERUSER)**
|
||||||
|
- Stat cards: Open orders count, Pending approval count, Completed orders
|
||||||
|
- Quick "New PO" call-to-action
|
||||||
|
- Link to full order list
|
||||||
|
|
||||||
|
**Manager view**
|
||||||
|
- Stat cards: Awaiting approval (clickable → approval queue), Approved this month, Total approved spend
|
||||||
|
- Recent approved POs table: PO number, title, vessel, amount, date
|
||||||
|
- Spend trend chart (monthly bar chart, last 6–12 months)
|
||||||
|
- Vessel spend breakdown chart (pie or bar)
|
||||||
|
|
||||||
|
**Accounts view**
|
||||||
|
- Stat cards: Ready for payment count, Total value awaiting payment
|
||||||
|
- Quick link to payment queue
|
||||||
|
|
||||||
|
**Auditor / Admin view**
|
||||||
|
- Total PO count with link to history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 My Purchase Orders `/my-orders`
|
||||||
|
|
||||||
|
Personal PO list for submitters.
|
||||||
|
|
||||||
|
**Open orders table** (DRAFT, SUBMITTED, MGR_REVIEW, VENDOR_ID_PENDING, EDITS_REQUESTED)
|
||||||
|
- Columns: PO Number, Title, Vessel, Status badge, Amount, Last updated
|
||||||
|
- Manager note displayed inline if status = EDITS_REQUESTED
|
||||||
|
|
||||||
|
**Past orders table** (MGR_APPROVED through CLOSED / REJECTED)
|
||||||
|
- Same columns
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- "New PO" button (top right)
|
||||||
|
- Click any row → PO detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Approval Queue `/approvals`
|
||||||
|
|
||||||
|
All POs awaiting manager decision (status = MGR_REVIEW).
|
||||||
|
|
||||||
|
Filter bar:
|
||||||
|
- Search (PO number, submitter name, title)
|
||||||
|
- Vessel dropdown
|
||||||
|
- Date from picker
|
||||||
|
|
||||||
|
Table columns: PO Number, Title, Submitter, Vessel, Amount, Submitted date
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- "Review" link per row → approval detail page
|
||||||
|
- Pending count shown in heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 PO Detail `/po/[id]`
|
||||||
|
|
||||||
|
Full read view of a single PO. Accessible to: the submitter (own POs), ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN.
|
||||||
|
|
||||||
|
**Header band**
|
||||||
|
- PO number (monospace)
|
||||||
|
- Status badge (colour-coded)
|
||||||
|
- Export PDF button
|
||||||
|
|
||||||
|
**Body sections**
|
||||||
|
|
||||||
|
*Summary panel*
|
||||||
|
- Title, vessel, account, vendor (if assigned), project code, date required, currency, total amount
|
||||||
|
|
||||||
|
*Line items table*
|
||||||
|
- Columns: Item name, Description, Qty, Unit, Unit price, GST rate, Total (incl. GST)
|
||||||
|
- Read-only
|
||||||
|
|
||||||
|
*Terms & Conditions*
|
||||||
|
- Delivery, Dispatch, Inspection, Transit insurance, Payment terms, Others
|
||||||
|
|
||||||
|
*Documents*
|
||||||
|
- Uploaded files with download links
|
||||||
|
|
||||||
|
*Audit trail*
|
||||||
|
- Chronological list of every action on the PO
|
||||||
|
- Each row: actor name, action type, timestamp, optional note
|
||||||
|
|
||||||
|
*Timestamps sidebar (or footer)*
|
||||||
|
- Created, Submitted, Approved, Paid, Closed
|
||||||
|
|
||||||
|
**Contextual action buttons** (shown/hidden based on status and role)
|
||||||
|
|
||||||
|
| Condition | Button |
|
||||||
|
|-----------|--------|
|
||||||
|
| Status = DRAFT or EDITS_REQUESTED + own submitter | Edit |
|
||||||
|
| Status = DRAFT + own submitter or MANAGER/SUPERUSER | Discard (delete draft) |
|
||||||
|
| Status = VENDOR_ID_PENDING + can provide vendor | Vendor selection form inline |
|
||||||
|
| Status = PAID_DELIVERED + own submitter or SUPERUSER | Confirm Receipt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 Approval Detail `/approvals/[id]`
|
||||||
|
|
||||||
|
Full PO view with approval action panel. MANAGER / SUPERUSER only.
|
||||||
|
|
||||||
|
Same content as PO detail, plus:
|
||||||
|
|
||||||
|
**Manager action panel**
|
||||||
|
- Approve button
|
||||||
|
- Approve with Note button (opens note textarea, then approves)
|
||||||
|
- Reject button (requires mandatory note)
|
||||||
|
- Request Edits button (requires mandatory note)
|
||||||
|
- Request Vendor ID button (sends back to submitter to supply vendor)
|
||||||
|
|
||||||
|
**Manager line-item edit form**
|
||||||
|
- Inline form allowing manager to adjust quantities, unit prices, GST rate, add/remove line items and change vessel, account, vendor before approving
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 New PO `/po/new`
|
||||||
|
|
||||||
|
Multi-section form to create a purchase order.
|
||||||
|
|
||||||
|
**Section 1 — Header**
|
||||||
|
- Title (required)
|
||||||
|
- Description / remarks
|
||||||
|
- Vessel (required, dropdown)
|
||||||
|
- Account / Cost Centre (required, dropdown)
|
||||||
|
- Vendor (optional, dropdown — can be added later)
|
||||||
|
- Date Required (date picker)
|
||||||
|
- Project Code
|
||||||
|
|
||||||
|
**Section 2 — Line Items**
|
||||||
|
- Dynamic table; rows can be added and removed
|
||||||
|
- Per-row fields: Name (searchable against item catalogue), Description, Qty, Unit, Size, Unit Price, GST Rate
|
||||||
|
- As-you-type name search shows matching products with per-vendor prices as hints
|
||||||
|
- Running totals shown below table: Taxable, GST, Grand Total
|
||||||
|
|
||||||
|
**Section 3 — Terms & Conditions**
|
||||||
|
- Delivery, Dispatch, Inspection, Transit Insurance, Payment Terms, Others (all text, optional)
|
||||||
|
|
||||||
|
**Section 4 — Documents**
|
||||||
|
- Drag-and-drop or browse file uploader
|
||||||
|
- Shows list of attached files
|
||||||
|
|
||||||
|
**Footer actions**
|
||||||
|
- Save as Draft
|
||||||
|
- Submit for Approval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7 Edit PO `/po/[id]/edit`
|
||||||
|
|
||||||
|
Identical form to New PO, pre-filled with existing data.
|
||||||
|
|
||||||
|
Available only when status = DRAFT or EDITS_REQUESTED, and the user is the submitter or SUPERUSER.
|
||||||
|
|
||||||
|
Footer actions:
|
||||||
|
- Save as Draft
|
||||||
|
- Update & Resubmit (only shown when status = EDITS_REQUESTED; transitions back to MGR_REVIEW)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.8 Import PO `/po/import`
|
||||||
|
|
||||||
|
Upload an Excel file in Pelagia's standard PO template format.
|
||||||
|
|
||||||
|
Steps (wizard-style or single page):
|
||||||
|
1. Drop / upload .xlsx file
|
||||||
|
2. System parses line items, vendor, quotation details
|
||||||
|
3. User selects Vessel and Account (not parsed from file)
|
||||||
|
4. Preview of extracted line items in editable table
|
||||||
|
5. Save as Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.9 Confirm Receipt `/po/[id]/receipt`
|
||||||
|
|
||||||
|
Receipt confirmation form. Shown only when status = PAID_DELIVERED.
|
||||||
|
|
||||||
|
- PO number and title shown as context
|
||||||
|
- File upload for delivery receipt document
|
||||||
|
- Optional notes field
|
||||||
|
- Submit button → transitions PAID_DELIVERED → CLOSED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.10 Payment Queue `/payments`
|
||||||
|
|
||||||
|
ACCOUNTS role only.
|
||||||
|
|
||||||
|
Card list of POs in MGR_APPROVED and SENT_FOR_PAYMENT statuses.
|
||||||
|
|
||||||
|
**Per card**
|
||||||
|
- PO number, title
|
||||||
|
- Vessel, Submitter, Vendor
|
||||||
|
- Approved date
|
||||||
|
- Amount (prominent)
|
||||||
|
- Status badge: "Ready for Payment" or "Processing — awaiting confirmation"
|
||||||
|
|
||||||
|
**Per card actions**
|
||||||
|
- MGR_APPROVED → "Send for Payment" button
|
||||||
|
- SENT_FOR_PAYMENT → "Mark as Paid" button
|
||||||
|
- View PO detail link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.11 History & Export `/history`
|
||||||
|
|
||||||
|
All POs in all statuses. MANAGER, SUPERUSER, ACCOUNTS, AUDITOR, ADMIN.
|
||||||
|
|
||||||
|
**Filter bar**
|
||||||
|
- Date range (from / to)
|
||||||
|
- Vessel dropdown
|
||||||
|
- Status dropdown
|
||||||
|
|
||||||
|
**Table columns**: PO Number, Title, Vessel, Submitter, Status badge, Amount, Created date
|
||||||
|
|
||||||
|
**Export buttons** (apply current filters to export)
|
||||||
|
- Export PDF
|
||||||
|
- Export CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.12 Vendor Registry `/admin/vendors`
|
||||||
|
|
||||||
|
Vendor list. MANAGER, ACCOUNTS, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Vendor ID (or "Pending"), Name, Contact (name + email), Item count, Verified badge, Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add Vendor button → modal form (GSTIN lookup, name, address, pincode auto-filled via GST portal captcha; manual contact fields)
|
||||||
|
- Edit / Delete per row
|
||||||
|
- Click vendor name → Vendor Detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.13 Vendor Detail `/admin/vendors/[id]`
|
||||||
|
|
||||||
|
**Header**
|
||||||
|
- Vendor name, vendor ID, verified / active badges
|
||||||
|
- Edit button
|
||||||
|
|
||||||
|
**Info card**
|
||||||
|
- GSTIN, address, pincode, contact name, mobile, email
|
||||||
|
|
||||||
|
**Items supplied table**
|
||||||
|
- Product code, name, last quoted price, last updated
|
||||||
|
- Click product name → Item Detail page
|
||||||
|
|
||||||
|
**Recent POs table**
|
||||||
|
- PO number, status, amount, created date (last 10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.14 GSTIN Lookup (modal / inline within vendor form)
|
||||||
|
|
||||||
|
Two-step flow embedded in the Add / Edit Vendor form:
|
||||||
|
|
||||||
|
1. User types a 15-character GSTIN and clicks "Look up"
|
||||||
|
2. System loads GST portal captcha image from the microservice → displays inline
|
||||||
|
3. User types the 6-digit captcha answer
|
||||||
|
4. User clicks "Verify" → microservice submits to GST portal → returns taxpayer data
|
||||||
|
5. Form auto-fills: name, address, pincode (lat/lng geocoded silently from pincode)
|
||||||
|
|
||||||
|
Error states: wrong captcha (shows error, resets), session expired (auto-reset), GST portal unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.15 Item Catalogue `/admin/products`
|
||||||
|
|
||||||
|
MANAGER, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Name, Code, Description, Vendor count, Last price, Last vendor, Updated date, Status badge
|
||||||
|
|
||||||
|
Footer note: "Items are added automatically when a PO is marked as paid."
|
||||||
|
|
||||||
|
**Actions** (ADMIN only)
|
||||||
|
- Add Product → modal form (code, name, description)
|
||||||
|
- Toggle Active / Inactive per row
|
||||||
|
- Delete per row
|
||||||
|
- Click name → Item Detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.16 Item Detail `/admin/products/[id]`
|
||||||
|
|
||||||
|
**Header**
|
||||||
|
- Name, code, status badge, description
|
||||||
|
- Add to Cart button
|
||||||
|
- Toggle Active button (ADMIN only)
|
||||||
|
|
||||||
|
**Stat cards**
|
||||||
|
- Vendor count, Lowest price, Highest price, Sites with stock
|
||||||
|
|
||||||
|
**Price comparison bar chart**
|
||||||
|
- One bar per vendor, Y-axis = unit price
|
||||||
|
|
||||||
|
**Site distance filter**
|
||||||
|
- Dropdown: "Sort by distance from site" — re-sorts vendor table by proximity
|
||||||
|
- Uses geocoded pincode of vendor vs site lat/lng for distance
|
||||||
|
|
||||||
|
**Vendor pricing table**
|
||||||
|
- Columns: Vendor (link to vendor detail), Verified badge, Unit price, Distance (if site selected), Last updated, Add to Cart
|
||||||
|
- Closest vendor gets a ★ marker when a site is selected
|
||||||
|
|
||||||
|
**Stock by site**
|
||||||
|
- Chip list: site name + quantity on hand (link to site detail)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.17 Vessel Management `/admin/vessels`
|
||||||
|
|
||||||
|
MANAGER, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Name, IMO Number, Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add / Edit / Delete per row (all modal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.18 Account / Cost Centre Management `/admin/accounts`
|
||||||
|
|
||||||
|
MANAGER, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Code, Name, Description, Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add / Edit / Delete per row (all modal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.19 Sites `/admin/sites`
|
||||||
|
|
||||||
|
MANAGER, ADMIN (ADMIN-only for add/edit/delete).
|
||||||
|
|
||||||
|
Ports, depots, and offices that hold inventory.
|
||||||
|
|
||||||
|
**Table columns**: Name, Code, Address, Vessels, Items tracked, Location (lat/lon from pincode), Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add Site → modal form (name, code, address, pincode for auto-geocoding)
|
||||||
|
- Edit / Delete per row
|
||||||
|
- Click name → Site Detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.20 Site Detail `/admin/sites/[id]`
|
||||||
|
|
||||||
|
**Header**
|
||||||
|
- Name, code, address, geocoded location
|
||||||
|
- Edit button (ADMIN only)
|
||||||
|
|
||||||
|
**Stat cards**
|
||||||
|
- Vessels at site, Items tracked, Total inventory value (if calculable)
|
||||||
|
|
||||||
|
**Inventory bar chart**
|
||||||
|
- X-axis = product name, Y-axis = quantity on hand
|
||||||
|
|
||||||
|
**Consumption line chart**
|
||||||
|
- Last 30 days of daily consumption, one line per product
|
||||||
|
|
||||||
|
**Inventory table**
|
||||||
|
- Product name, quantity on hand, last updated; link to item detail
|
||||||
|
|
||||||
|
**Log consumption form**
|
||||||
|
- Fields: Product (dropdown), Date (date picker), Quantity, Note
|
||||||
|
- Submits immediately; chart and table refresh
|
||||||
|
|
||||||
|
**Assigned vessels**
|
||||||
|
- Chip list linking to vessel detail
|
||||||
|
|
||||||
|
**Recent POs for this site**
|
||||||
|
- Last 8 POs with status, vendor, amount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.21 User Management `/admin/users`
|
||||||
|
|
||||||
|
ADMIN only.
|
||||||
|
|
||||||
|
**Table columns**: Employee ID, Name, Email, Role badge, Status badge, Created date
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add User → modal form (employee ID, name, email, role, initial password)
|
||||||
|
- Edit → modal form (same fields, password optional)
|
||||||
|
- Delete per row
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.22 Cart `/inventory/cart`
|
||||||
|
|
||||||
|
Persistent cart collecting items selected from product detail pages. Stored in localStorage.
|
||||||
|
|
||||||
|
**Cart view**
|
||||||
|
- Item list: product name, description, vendor (if selected), unit price, quantity (editable inline)
|
||||||
|
- Summary: subtotal, GST, grand total
|
||||||
|
- Site selector (to indicate delivery site)
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Remove item
|
||||||
|
- Clear cart
|
||||||
|
- Create PO → opens New PO form pre-filled with cart line items and selected site/vendor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PO Lifecycle State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
▼ │
|
||||||
|
[DRAFT] ──submit──► [SUBMITTED] ──auto──► [MGR_REVIEW]
|
||||||
|
│ │ │ │
|
||||||
|
approve ◄───────┘ │ │ └──── reject ──► [REJECTED]
|
||||||
|
│ │ │
|
||||||
|
│ request_edits─┘ └── request_vendor_id ──► [VENDOR_ID_PENDING]
|
||||||
|
│ │
|
||||||
|
│ ◄──── provide_vendor_id ──────────────────────┘
|
||||||
|
│
|
||||||
|
[MGR_APPROVED]
|
||||||
|
│
|
||||||
|
process_payment
|
||||||
|
│
|
||||||
|
[SENT_FOR_PAYMENT]
|
||||||
|
│
|
||||||
|
mark_paid
|
||||||
|
│
|
||||||
|
[PAID_DELIVERED]
|
||||||
|
│
|
||||||
|
confirm_receipt
|
||||||
|
│
|
||||||
|
[CLOSED]
|
||||||
|
```
|
||||||
|
|
||||||
|
States that allow re-entry into the flow:
|
||||||
|
- **EDITS_REQUESTED** → submitter edits PO → re-submits → MGR_REVIEW
|
||||||
|
- **VENDOR_ID_PENDING** → submitter selects vendor → MGR_REVIEW
|
||||||
|
|
||||||
|
Terminal states: **REJECTED**, **CLOSED**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Workflows
|
||||||
|
|
||||||
|
### 7.1 Submit a Purchase Order (TECHNICAL / MANNING)
|
||||||
|
|
||||||
|
1. Click **New PO** in sidebar
|
||||||
|
2. Select vessel and account
|
||||||
|
3. Add line items (type name to search item catalogue; previous vendor prices appear as hints)
|
||||||
|
4. Optionally attach documents and fill in T&C fields
|
||||||
|
5. Click **Submit for Approval**
|
||||||
|
6. Manager receives email notification
|
||||||
|
7. Status shows as "Under Review" on My Orders page
|
||||||
|
8. If manager requests edits: submitter sees EDITS_REQUESTED status with manager note; edits form; resubmits
|
||||||
|
9. If manager requests vendor ID: submitter selects a vendor and submits; returns to manager queue
|
||||||
|
10. On approval: submitter notified by email; accounts team can see PO in payment queue
|
||||||
|
|
||||||
|
### 7.2 Approve a Purchase Order (MANAGER)
|
||||||
|
|
||||||
|
1. Click **Approvals** in sidebar; see count of pending POs
|
||||||
|
2. Click **Review** on a PO
|
||||||
|
3. Read full detail: line items, vendor, documents, submitter notes
|
||||||
|
4. Optionally: click **Edit** to adjust line items, change vendor, vessel, or account
|
||||||
|
5. Choose action:
|
||||||
|
- **Approve** → immediately moves to accounts payment queue
|
||||||
|
- **Approve with Note** → same, with a note visible to submitter
|
||||||
|
- **Request Edits** → write note explaining required changes; PO returned to submitter
|
||||||
|
- **Request Vendor ID** → PO returned to submitter to select vendor; then returns to manager queue
|
||||||
|
- **Reject** → write reason; PO is closed permanently
|
||||||
|
|
||||||
|
### 7.3 Process a Payment (ACCOUNTS)
|
||||||
|
|
||||||
|
1. Click **Payments** in sidebar
|
||||||
|
2. See cards for all MGR_APPROVED POs
|
||||||
|
3. Click **Send for Payment** → initiates payment; notifies submitter and manager
|
||||||
|
4. When payment is confirmed by bank/finance: click **Mark as Paid** → notifies all parties
|
||||||
|
5. Submitter can now upload delivery receipt
|
||||||
|
|
||||||
|
### 7.4 Confirm Receipt (TECHNICAL / MANNING)
|
||||||
|
|
||||||
|
1. Goods are delivered on site / to vessel
|
||||||
|
2. Navigate to PO detail page (status = PAID_DELIVERED)
|
||||||
|
3. Click **Confirm Receipt**
|
||||||
|
4. Upload delivery receipt document and optionally add notes
|
||||||
|
5. Submit → PO is CLOSED; accounts and manager notified
|
||||||
|
|
||||||
|
### 7.5 Look Up a Vendor by GSTIN (MANAGER / ADMIN)
|
||||||
|
|
||||||
|
1. Open Add/Edit Vendor modal
|
||||||
|
2. Type the 15-digit GSTIN
|
||||||
|
3. Click **Look up** → captcha image loads from GST portal (via microservice)
|
||||||
|
4. Type the 6-digit captcha shown in the image
|
||||||
|
5. Click **Verify** → form auto-fills with legal name, trade name, registered address, pincode
|
||||||
|
6. Review and save; location is geocoded silently from pincode for distance calculations
|
||||||
|
|
||||||
|
### 7.6 Source Items by Proximity (MANAGER)
|
||||||
|
|
||||||
|
1. Navigate to **Items** → click an item name
|
||||||
|
2. See all vendors that supply the item with their last quoted price
|
||||||
|
3. Select a **site** from the "Sort by distance from" dropdown
|
||||||
|
4. Table re-sorts: vendors nearest to the site appear first; distance shown per row; closest vendor marked ★
|
||||||
|
5. Click **Add to Cart** on the desired vendor row → item added to cart
|
||||||
|
|
||||||
|
### 7.7 Create a PO from the Cart (MANAGER / TECHNICAL)
|
||||||
|
|
||||||
|
1. Browse Item catalogue and add items to cart (Add to Cart button per vendor row)
|
||||||
|
2. Click **Cart** in sidebar
|
||||||
|
3. Review cart: adjust quantities inline; remove items; select delivery site
|
||||||
|
4. Click **Create PO** → opens New PO form pre-filled with all cart items and vendor
|
||||||
|
5. Fill in title, vessel, account; submit normally
|
||||||
|
|
||||||
|
### 7.8 Track Inventory at a Site (MANAGER / ADMIN)
|
||||||
|
|
||||||
|
1. Navigate to **Sites** → click a site
|
||||||
|
2. View bar chart of current stock (quantity per product)
|
||||||
|
3. View consumption line chart (last 30 days)
|
||||||
|
4. Use **Log Consumption** form to record daily drawdown: select product, pick date, enter quantity
|
||||||
|
|
||||||
|
### 7.9 Auto-sync Catalogue on Payment Confirmation (ACCOUNTS → SYSTEM)
|
||||||
|
|
||||||
|
When accounts clicks **Mark as Paid**:
|
||||||
|
- System checks each PO line item that has a product link
|
||||||
|
- For unlinked items: attempts fuzzy-match on name; creates new product record if no match
|
||||||
|
- Upserts `ProductVendorPrice` — if this vendor/product combination is new or the price changed, updates the catalogue
|
||||||
|
- Sets `Product.lastPrice` and `Product.lastVendorId`
|
||||||
|
- Future POs using that product name will see this vendor's latest price as a hint
|
||||||
|
|
||||||
|
### 7.10 Import a PO from Excel (MANAGER)
|
||||||
|
|
||||||
|
1. Navigate to **Import PO**
|
||||||
|
2. Upload an Excel file in Pelagia's standard template format
|
||||||
|
3. System extracts: line items (name, description, qty, unit, price, GST), vendor details, quotation number/date
|
||||||
|
4. User selects vessel and account from dropdowns
|
||||||
|
5. Review and optionally edit extracted line items
|
||||||
|
6. Save as Draft → PO created; submitter can then edit and submit
|
||||||
|
|
||||||
|
### 7.11 Export PO History (AUDITOR / MANAGER)
|
||||||
|
|
||||||
|
1. Navigate to **History**
|
||||||
|
2. Apply filters: date range, vessel, status
|
||||||
|
3. Click **Export PDF** or **Export CSV**
|
||||||
|
4. File downloaded with all matching POs; up to 200 results per export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Entities
|
||||||
|
|
||||||
|
### Purchase Order
|
||||||
|
Fields: PO number (auto-generated), title, status, total amount, currency, date required, project code, manager note, payment reference, quotation number/date, requisition number/date, place of delivery, all T&C text fields, timestamps.
|
||||||
|
|
||||||
|
### PO Line Item
|
||||||
|
Fields: name, description, quantity, unit, size, unit price, GST rate (default 18%), total price (computed), sort order, optional product link.
|
||||||
|
|
||||||
|
### Vendor
|
||||||
|
Fields: name, vendor ID (optional, unique), address, pincode, GSTIN, contact name/mobile/email, latitude/longitude (geocoded silently from pincode), verified flag, active flag.
|
||||||
|
|
||||||
|
### Product (Item)
|
||||||
|
Fields: code (auto-generated or manual), name, description, last price, last vendor, active flag. Prices tracked per vendor via `ProductVendorPrice` (one record per product–vendor pair).
|
||||||
|
|
||||||
|
### Vessel
|
||||||
|
Fields: name, IMO number (optional), active flag, assigned site (optional).
|
||||||
|
|
||||||
|
### Site
|
||||||
|
Fields: name, code, address, pincode, latitude/longitude, active flag.
|
||||||
|
|
||||||
|
### Account (Cost Centre)
|
||||||
|
Fields: code, name, description, active flag.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Fields: employee ID, email, name, role, active flag, password hash.
|
||||||
|
|
||||||
|
### Inventory & Consumption
|
||||||
|
- `ItemInventory`: quantity of a product at a site (one row per product–site pair)
|
||||||
|
- `ItemConsumption`: daily draw-down record (one row per product–site–date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Key UI Patterns
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
Each PO status has a distinct colour:
|
||||||
|
- DRAFT — neutral grey
|
||||||
|
- SUBMITTED / MGR_REVIEW — blue (in-progress)
|
||||||
|
- VENDOR_ID_PENDING — orange/warning
|
||||||
|
- EDITS_REQUESTED — yellow/warning
|
||||||
|
- MGR_APPROVED — teal/success-adjacent
|
||||||
|
- SENT_FOR_PAYMENT — purple
|
||||||
|
- PAID_DELIVERED — blue-green
|
||||||
|
- CLOSED — green/success
|
||||||
|
- REJECTED — red/danger
|
||||||
|
|
||||||
|
### Confirmation before Destructive Actions
|
||||||
|
Delete buttons use a two-step inline confirm: "Delete [name]? Confirm / Cancel". No modal dialog — the confirm state replaces the button in-place.
|
||||||
|
|
||||||
|
### Inline Editing in Tables
|
||||||
|
Manager line-item editing in the approval flow happens in an inline form on the same page, not in a modal, so the manager can reference the rest of the PO while editing.
|
||||||
|
|
||||||
|
### GST Calculation (always visible in PO forms)
|
||||||
|
Below the line-items table, a live summary shows:
|
||||||
|
- Taxable amount (sum of qty × unit price)
|
||||||
|
- GST amount (sum of qty × unit price × GST rate)
|
||||||
|
- Grand Total (taxable + GST)
|
||||||
|
|
||||||
|
### Product Autocomplete
|
||||||
|
In the PO line-item name field, typing triggers a fuzzy search of the item catalogue. Dropdown shows:
|
||||||
|
- Product name and code
|
||||||
|
- Price hints per vendor: "Vendor A: ₹1,200 · Vendor B: ₹1,050"
|
||||||
|
|
||||||
|
### Cart Persistence
|
||||||
|
Cart is stored in browser `localStorage` under a fixed key. It survives navigation but is local to the device and user. A `cart-updated` custom event allows components to react to changes in real time.
|
||||||
|
|
||||||
|
### Notifications / Emails
|
||||||
|
Every PO status transition triggers an email to relevant parties:
|
||||||
|
- Submit → manager
|
||||||
|
- Approve → submitter + accounts
|
||||||
|
- Reject → submitter
|
||||||
|
- Request Edits → submitter
|
||||||
|
- Request Vendor ID → submitter
|
||||||
|
- Payment sent → submitter + manager
|
||||||
|
- Mark paid → submitter + manager
|
||||||
|
- Receipt confirmed → manager + accounts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Non-Goals (Out of Scope)
|
||||||
|
|
||||||
|
- Mobile app (web-only, desktop-first)
|
||||||
|
- Public-facing pages (entirely internal)
|
||||||
|
- Self-registration / OAuth login
|
||||||
|
- Vendor portal (vendors do not log in)
|
||||||
|
- Automated bank/payment-gateway integration (payment is marked manually)
|
||||||
74
Progress/PMS_HNR3_056_2026-27.pdf
Normal file
74
Progress/PMS_HNR3_056_2026-27.pdf
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
%PDF-1.4
|
||||||
|
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||||
|
1 0 obj
|
||||||
|
<<
|
||||||
|
/F1 2 0 R /F2 3 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 8 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 7 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<<
|
||||||
|
/PageMode /UseNone /Pages 7 0 R /Type /Catalog
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<<
|
||||||
|
/Author (\(anonymous\)) /CreationDate (D:20260505165122+05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260505165122+05'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||||
|
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<<
|
||||||
|
/Count 1 /Kids [ 4 0 R ] /Type /Pages
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
8 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 3962
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gau_l?$G;:npQ#,]X0Wb%?;^WZCRnVK^R9+#RbHpg=$iCSDZ$Pe<oUYqs3_G6h1Ia&CY^gac])anVlU/RZTpfi"+(]cdr.U1V!:L!bF'4#0IAbZ]?f-^&.[I/MK1>#dnIVCr\CQS)\8i,oJOgH%uO$U=hmhCF$u_<(;tGj<t[r`GL:S(2tuLI-eTip>N_M)3+nT*D@N0[t?,g+(ke*SDF,DZbKoA,P5`SiKQObc_5(+in1V_4t?t8APto56AT)K&'rS<g]qc&M#H`7)QC>3:<=g[0mjgD_KZeA^hiTTVZ&]7ia)ugFm6juppl'?pDUjGDZ/W"4i#Yd-)D;U4rPl'fhDh&@"K+kbl$!Kr7e&2`d5KJ0"?QpE!:m%(Eb!nkK>QHH`NbsrlTQo]X8O\Tl8."U^QqE;t`!!S.GaRfJ]d$;;n:6JsK;2pkk\N$>CjUhhBaM2-A?`)!C/</"!cni^`B.Pddnq-&`C&0WC6*kK3+HrcA,NPInY!a\tRTaMZ>ia5iC!Bosc#WGPb6"L[_TJN?u<8DhY!:a:!8hSo)_\+S]'?MH76U2)3++`GAhBin<')E)_O`u.^oY[,OZBo2`OeVDPj]HWZO@YBRZZV1=uiDq>)dg^@=K3?(ZWhMR.nXE^]GQF[/AeBJAou9\QnC6Hp<J\uV?@NX_;SeXJFd0QGR)$:9hN+;(GjW=$I#ZR]Fp4L];;rlf'HZar!sUuLDaRq#)Vh-s[.g=;5C"b*CE:",>+)[ka=s\eH(nk+_rd%fnFK^!gIV9Y(>pN4,FmG4K]=HiE>A<?3N5.g-!gabT8[1o9W4mVEKn43Kp179I9@!nD<>$4c;Lkb-nqPUQSJ`F5lpu7QDT=(\tpiSFL'[HY.+B;*kmACP4I1Q]6=n.i7r=f,W\\"[.l#g7CMKtICAM$;BES^FDN8@Mt(S74-3u]Zqg_9PW5JFYW'25Mk@f5pD0kn(p9PZGK$OD)H'HiT;@i<Rh/'5atd9(mcPMt$\G8E.,;c@Hfo;\IfVLc>,="qgMe-fFd2=R=)YQRH7KimB,ZFVbB?YW<)U<L@?8&N[Y1VZ_)=ijMK4qYUi@"2V3I=gV;XSn<\'\3BOmOu\F8c?QcAF4]>*%;dHcXdmBg,iAD+C:D:S3T,u.+]$L_qs0#/r.j&W_m`DMbnM3T#B9@@jRBN*-uRF*gp*%3MISD7RV%[R8$2DK'H<_K.WHO4e=Y4)iL#6hV#_0=^jlI0YJd_,-)N1sspo2ic/+1F<C`Ef/&ReE_N:<`1"ni?tr'#;aE>^3d0q"X1,./`(YE3o1C^$lYPDslNfWGHbNWXc87,=GA2aYqrV95`/FqugfBm;^qr8aTn<b@suk,:B8:9raZ*o.+/BoA]beT0Hj!A6H4:3kb>Og4dZB5gfCV3ePht2=g#+5MV8NgTb\\+6!`\cZqApEHG+:/%icN?\"1A5*gVb8[?F'KAu"Dn?Yfd>PN4$JE;(4n8."OO++TpnspMC&,j/WESSf`[j9hf?r.<M`GV0`!P0)Y<r6?[3cS(un.QW'-8Xpl_JFB'pVXUI`=uC7IdDD8>C,Mr752V)o$GE!2B/$.=I%gMNkY>>qZbLp/08)_bBkQ)1$iaD;bnEoE_#NS^8DGgp[jN-RVY7^XdlhAE#XLf2bsEH\1O"$=]ZqPC%fK)ZuDBQF4sT<*ucm@^+=n:+n-;Thfu-/2]"BiouaQCDV+H(<ff"^bui0@Ie(kZBilH93$ZK0/l<!;iLC\:$L_qE(LS!2]H6f8)qg*,1MJI3[V!]K!H&?oPOX$9O;Z2[Yp-LX2#g(pALT!D`A3@:PNuXW)#Nh#@j>^O)E;?[%*RjVE(5TZ$/;gt.5<\Lat7p^fR[eo0[oaL3UA?Y_5Q8qq`B9hO+U/L+5X&nlj&<`$ms7uJn%Vc>21Z@nMq!j`P)i:<s!LRRO4):pq$_TN4G,OVaPBoH?IRuHV52i&,/12@(h@F^oE2$itutMV%%/VqjA#=+bu`ZShga*>:j7sfiabhs7)[7LYkSUUfuJQE"3R0bRbVHB?^Mn8kb.8QV=kAhl-q3JbqSX%"I$>cLXNL25X$rWPpD\E4N$KToO,-,8Jf\470e*lMG!elO)DK(6bJXpo"6tPJe??0*YhbK/#@H2j_>W6%8qH_tIYE-A407Ai5sXRbAjI\FdQTW')/k_**ck8lR^CQc((J#HRp[o>)=BR;(<6S#r\.J(,hp-W#T,Vh-]O?kS)PSBA0+lI&=_nPkoE*MYb.SDs^=C)fN3/UUXC67&Qn$(U#X\DNs)eXrB_0^Fn"NMVLWe1f#b:l0?'[hlQV9,SG+;Ltmae7,CbdaM*#Y=DO:WC8=C8<9X,X@4YATr41s?k^_[8(=BS;X2]Z;X2]*.<T@P8p0CGPe+58V7Q<K^pOUjEV.u5,"ZA1.7mh0Z)"D,=@uM</?($LQI:tg(5Pp'Jo\ProN5!g7qbIg*C5mhd\A6s*C5n3d\A7.*MEZWQWNGZ=0F5>Q;lHJ>#JWrd%n8FBM)Q#d%n8FBM)Q#W*tc+_D[g-/8+4Ob+,<)<TYha>+)^=BU,*a74WGF%]to#p<[p;#??7T)h7G38Y(YAp>LHg%B2H!`oXkT>Wn9U:ueKLa0$T-B*nsY,a5fs,bHM2C)WsZebUl_R7)'HK:$?hUk5D68L1[IE#C8:[8W=LR7(NK)2I.EhGZ"t'b(q@HY:o:.D@b58SpD-c)a92HuGZ3.WW;JaN-X\aYIq@bg0XR5m,4S<pl1)6&Q^>O`2)(7qP;4cp6TLk5La=aicsZWO7;qCiO1l8^/u\-?A'9]sWL"?Pq?,M-2YGgQP/iL.i^.)DZSO:h8R!TH'm]b\g_<]T/:pKh$'hC[\d5N,g*qrU0VL!HWY_cIak0Zt$&+=??uB[VA<0XX//7+rtT6V6-"Ie&n[M:srOAg\i[S1,u*V9R^\6b,?4FkM'.aQ::4XT9=-EOO`erkooCt]A/Sjdr`'W;su+4d2gFPB)VG38_L]?e26JlYA-l3C24PKnSEaAcN\XSCnUI\=3G/,\PQjF3D_j")O3VM5XErcPeSb>-2c[NV8.l<-Nu]HkjZLQ($b3;'/m!BgtfS5U=LQI.YP8NATi/q7SH0OY&$N;8(6/PoBgAh0Uea9_W,$GpFr"9DYR8[:N3<LZQ=*9WkUnfO'K\tVV=[t=Bp#AHKY!:]5bM$YPe#>o&%&-nKD$>>M.$Oa\d4C3K+AkNad7i`Q8b5o4YhoV0CJ=o:@OYi<"W/V?FWua,(Uc621LQPZ%t,Kc_Z/Qa0;(re]2aH`O`hEP8k$HlP<[-GNp*DZS"9&Y)I._8?*#IWUVcFJgX3%2mF"b1M^>`SGJoma#c85W+Y,@2?Zm\k)1AgJ&B[QO-O[l@Jji5SkoQ0n>&ZSt]<DJ=g=9GZc@Ih/,'N^lSN'L&TW)7Tici4%9Zjn[C!K)qQq"N<bl/Y$oi38C_QUg5sI'X&Rob?HdBW0ceP;&,;)jD[/dclaG,T#UeJV4Ol1Joma^uN+Oa_9^\f.OA1lA0g*M()A^;G3*kD;-dn=Z<ft"*Xl"Nq_g/VI]b_c^G,m:#n`F2kQS_R;$/EGr]n"AMgEsblJ5M!YqrD[ZBUd"$7f@5jVeI37^VM7W(;"jDb<n`FV6T_,5J>rq86r?U3j!U'k_\,b'!Z"RVi@:->MD<NjmKsa^#tW=7.'9..'Ke]mV]Z)8?6SA3Ut[;+2s[$mHM"FqJ;aTEHD!=?!kY-P[:P5KKSd?]d;I7,9EeZ\7mW)2__@NK_/%g>_erA72m2qoLP%TY)GEd<Oc?UFY=6JZFRt7@k0+Jju+l!Xk`]Q?i00XP%>=4s2Be_"tu[:7.E52*i=e'hNW^qNL!%FX`_?Ug&H'/Dm(_cs):OhV;Zl`i_)7WQalmI9pN1F8o1QdPn#lMhDPhgKfTgl-EhR*)ZH_T2Da`*LdnYOT!oKi'oYLjR,d>G[q%W*5FAThcml".$N3'h_RogKh2<eR~>endstream
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 9
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000061 00000 n
|
||||||
|
0000000102 00000 n
|
||||||
|
0000000209 00000 n
|
||||||
|
0000000321 00000 n
|
||||||
|
0000000524 00000 n
|
||||||
|
0000000592 00000 n
|
||||||
|
0000000872 00000 n
|
||||||
|
0000000931 00000 n
|
||||||
|
trailer
|
||||||
|
<<
|
||||||
|
/ID
|
||||||
|
[<9a63d4b4df81edbfadf21abb64ea348a><9a63d4b4df81edbfadf21abb64ea348a>]
|
||||||
|
% ReportLab generated PDF document -- digest (opensource)
|
||||||
|
|
||||||
|
/Info 6 0 R
|
||||||
|
/Root 5 0 R
|
||||||
|
/Size 9
|
||||||
|
>>
|
||||||
|
startxref
|
||||||
|
4984
|
||||||
|
%%EOF
|
||||||
BIN
Progress/PMS_HNR3_056_2026-27.xlsx
Normal file
BIN
Progress/PMS_HNR3_056_2026-27.xlsx
Normal file
Binary file not shown.
285
Progress/PROGRESS.md
Normal file
285
Progress/PROGRESS.md
Normal file
|
|
@ -0,0 +1,285 @@
|
||||||
|
# Pelagia Portal — Build Progress
|
||||||
|
|
||||||
|
Last updated: 2026-05-05
|
||||||
|
|
||||||
|
Legend: ✅ Complete · ⚠️ Partial (works but incomplete) · ❌ Not started
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Infrastructure & Config
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Next.js 15 App Router project | ✅ | Turbopack dev, strict TS |
|
||||||
|
| Tailwind CSS v4 + design tokens | ✅ | Colour palette matches spec |
|
||||||
|
| Prisma schema — all models | ✅ | Product, gstRate, piQuotationNo/Date, requisitionNo/Date, placeOfDelivery, tcDelivery/tcDispatch/tcInspection/tcTransitInsurance/tcPaymentTerms/tcOthers, address/gstin/contactMobile on Vendor — all migrated (latest: `structured_tc_fields`) |
|
||||||
|
| Database seed script | ✅ | 5 users, 3 vessels, 3 accounts, 3 vendors, 3 sample POs, 4 products |
|
||||||
|
| Dev / production environment split | ✅ | `NODE_ENV` gates R2 vs local storage, Resend vs console log |
|
||||||
|
| `.env.example` + `.env` / `.env.local` docs | ✅ | README covers full setup |
|
||||||
|
| README with dev + prod setup guide | ✅ | |
|
||||||
|
| CI/CD (GitHub Actions) | ❌ | No workflow files yet |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication & Authorisation
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Login page (credentials) | ✅ | Email + password, bcrypt hash |
|
||||||
|
| NextAuth.js v5 session | ✅ | Database sessions, role in JWT |
|
||||||
|
| Auth middleware (route protection) | ✅ | Redirects unauthenticated to `/login` |
|
||||||
|
| Role permissions matrix (`lib/permissions.ts`) | ✅ | All 7 roles; `manage_products` added for Admin |
|
||||||
|
| SSO / Azure AD login | ❌ | Open question — credentials only for v1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## PO State Machine (`lib/po-state-machine.ts`)
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| All 10 statuses defined | ✅ | |
|
||||||
|
| All transitions with role guards | ✅ | |
|
||||||
|
| `canPerformAction`, `getAvailableActions` helpers | ✅ | |
|
||||||
|
| `requiresNote` helper | ✅ | |
|
||||||
|
| Unit tests | ✅ | `tests/unit/po-state-machine.test.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Notifications (`lib/notifier.ts`)
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Notifier module (Resend in prod, console in dev) | ✅ | |
|
||||||
|
| Email templates (all 7 events) | ✅ | React Email components in `/emails/` |
|
||||||
|
| Notify on PO submitted | ✅ | Notifies managers + submitter (confirmation) |
|
||||||
|
| Notify on PO approved / rejected / edits requested | ✅ | Per notification matrix |
|
||||||
|
| Notify on vendor ID requested / provided | ✅ | |
|
||||||
|
| Notify on payment processed | ✅ | Notifies managers + accounts on processing; submitter + managers on confirmed |
|
||||||
|
| Notify on receipt confirmed | ✅ | Notifies managers + accounts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Storage (`lib/storage.ts`)
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Dev local storage (`/api/files/dev/[...key]`) | ✅ | Auth-gated, path-traversal protected |
|
||||||
|
| Prod Cloudflare R2 presigned URLs | ✅ | Upload + download URL generation |
|
||||||
|
| Sign API (`/api/files/sign`) | ✅ | Returns `{ uploadUrl, key }` |
|
||||||
|
| File upload UI on New PO form | ✅ | `FileUploader` component + `uploadAndLinkFiles` utility; PO created first, then files signed+uploaded+linked |
|
||||||
|
| File upload UI on Receipt form | ✅ | Same pattern, type `"receipt"` |
|
||||||
|
| Document download links on PO detail | ✅ | `PoDetail` made async; `generateDownloadUrl` called server-side; filenames are clickable links |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pages & Server Actions
|
||||||
|
|
||||||
|
### Login
|
||||||
|
| Item | Status |
|
||||||
|
|---|---|
|
||||||
|
| Login page + credentials form | ✅ |
|
||||||
|
|
||||||
|
### Dashboard
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Submitter dashboard (stat cards + New PO CTA) | ✅ | Recent orders table; link to My Orders |
|
||||||
|
| Manager dashboard (stat cards) | ✅ | |
|
||||||
|
| Manager dashboard — approved POs listing | ✅ | Recent approved/in-progress POs table with vessel, status, amount |
|
||||||
|
| Manager dashboard — spend by vessel breakdown | ✅ | Recharts bar chart (top 5 vessels) |
|
||||||
|
| Manager spend-by-vessel bar chart | ✅ | `SpendCharts` component using Recharts |
|
||||||
|
| Manager spend-by-month bar chart | ✅ | Last 12 months, bar chart |
|
||||||
|
| Accounts dashboard (stat cards) | ✅ | |
|
||||||
|
| Auditor / Admin / generic dashboard | ✅ | Single total-count card |
|
||||||
|
|
||||||
|
### New PO
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Form (title, vessel, account, vendor, project code, date) | ✅ | |
|
||||||
|
| Line items — UoM dropdown (15 options) | ✅ | Replaces free-text unit field |
|
||||||
|
| Line items — Size field | ✅ | Optional free-text |
|
||||||
|
| Line items — GST rate per item (0 / 5 / 12 / 18 / 28%) | ✅ | Default 18%; taxable / GST / grand-total breakdown shown live in editor |
|
||||||
|
| PI / Quotation No. + Date | ✅ | Separate section on form |
|
||||||
|
| Vessel / Office Requisition No. + Date | ✅ | Separate section on form |
|
||||||
|
| Place of Delivery | ✅ | Pre-filled with company delivery address; editable |
|
||||||
|
| Terms & Conditions — structured fields | ✅ | Fixed line 1 (read-only); separate inputs for Delivery, Dispatch Instructions, Inspection, Transit Insurance, Payment Terms, Others (multiline); all pre-filled with defaults |
|
||||||
|
| Currency defaults to INR | ✅ | Changed from USD → INR throughout |
|
||||||
|
| Save as draft | ✅ | |
|
||||||
|
| Submit for approval (→ MGR_REVIEW) | ✅ | |
|
||||||
|
| Document file upload | ✅ | Drag-and-drop + click; signed upload after PO creation |
|
||||||
|
|
||||||
|
### PO Detail (`/po/[id]`)
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Full detail view (info, vendor, line items, activity trail) | ✅ | |
|
||||||
|
| New fields displayed (PI/Quotation, Requisition, Delivery, Approved By) | ✅ | |
|
||||||
|
| Line items with GST breakdown (taxable / GST / grand total) | ✅ | |
|
||||||
|
| Vendor detail (address, GSTIN, contact name + mobile + email) | ✅ | |
|
||||||
|
| Terms & Conditions block (structured display) | ✅ | Fixed line 1 always shown; each labeled field (Delivery, Dispatch, etc.) on its own line |
|
||||||
|
| Export PDF button | ✅ | `/api/po/[id]/export?format=pdf` — auto-triggers print dialog; matches Sample_PO layout |
|
||||||
|
| Export XLSX button | ✅ | `/api/po/[id]/export?format=xlsx` — SheetJS; matches Sample_PO column layout |
|
||||||
|
| Confirm Receipt CTA (PAID_DELIVERED state) | ✅ | |
|
||||||
|
| Document download links | ✅ | Clickable links with server-generated presigned/dev URLs |
|
||||||
|
| Edit PO link (DRAFT / EDITS_REQUESTED) | ✅ | |
|
||||||
|
|
||||||
|
### PO Edit (`/po/[id]/edit`)
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Edit page | ✅ | Pre-populated form including all new fields |
|
||||||
|
| Update draft action | ✅ | |
|
||||||
|
| Resubmit after edits | ✅ | Shows manager note banner + Resubmit button |
|
||||||
|
|
||||||
|
### Vendor ID Flow
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Request vendor ID action (manager) | ✅ | Sets status to `VENDOR_ID_PENDING` |
|
||||||
|
| Provide vendor ID UI (submitter / manager) | ✅ | Inline form on PO detail for VENDOR_ID_PENDING |
|
||||||
|
| Provide vendor ID server action | ✅ | Transitions back to `MGR_REVIEW`, notifies managers |
|
||||||
|
|
||||||
|
### Approvals
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Approval queue list | ✅ | |
|
||||||
|
| PO detail + decision view | ✅ | |
|
||||||
|
| Approve / Approve+Note actions | ✅ | |
|
||||||
|
| Reject action | ✅ | |
|
||||||
|
| Request edits action | ✅ | |
|
||||||
|
| Request vendor ID action | ✅ | |
|
||||||
|
| Manager edit line items (amber edit mode) | ✅ | Saves original snapshot to POAction; diff shown with strike-through |
|
||||||
|
| Search / filter (PO number, vessel, submitter, date) | ✅ | URL search params; `ApprovalsSearch` client component |
|
||||||
|
|
||||||
|
### Payments
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Payment queue list (MGR_APPROVED + SENT_FOR_PAYMENT) | ✅ | |
|
||||||
|
| Start payment processing (MGR_APPROVED → SENT_FOR_PAYMENT) | ✅ | Step 1 |
|
||||||
|
| Confirm payment sent with ref (SENT_FOR_PAYMENT → PAID_DELIVERED) | ✅ | Step 2 — auto-updates Product.lastPrice for linked line items |
|
||||||
|
|
||||||
|
### My Orders (`/my-orders`)
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| My Orders page — all POs with open/past grouping | ✅ | Links to individual PO detail; shows manager note inline |
|
||||||
|
|
||||||
|
### Receipt Confirmation
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Notes + file receipt confirmation | ✅ | Closes PO to `CLOSED`; optional file attachment via `FileUploader` |
|
||||||
|
|
||||||
|
### History
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| All-POs list (latest 200 by default) | ✅ | |
|
||||||
|
| CSV export | ✅ | Respects active filters |
|
||||||
|
| PDF export (bulk) | ✅ | Print-optimised HTML page (`/api/reports/export?format=pdf`); auto-triggers browser print dialog |
|
||||||
|
| Date range filter | ✅ | URL search params; `HistoryFilters` client component |
|
||||||
|
| Vessel / status filters | ✅ | Same component |
|
||||||
|
|
||||||
|
### Admin — User Management
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| User list | ✅ | |
|
||||||
|
| Add user form + action | ✅ | `AdminDialog` modal; bcrypt password hash |
|
||||||
|
| Edit user form + action | ✅ | Optional password change |
|
||||||
|
| Deactivate / reactivate user | ✅ | Cannot deactivate own account |
|
||||||
|
|
||||||
|
### Admin — Vendor Management
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Vendor list | ✅ | |
|
||||||
|
| Add vendor form + action | ✅ | `AdminDialog` modal; sets `isVerified` if vendorId provided |
|
||||||
|
| Edit vendor form + action | ✅ | Includes address, GSTIN, contact mobile |
|
||||||
|
| Deactivate / reactivate vendor | ✅ | |
|
||||||
|
|
||||||
|
### Admin — Vessel Management
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Vessel list page | ✅ | `/admin/vessels` |
|
||||||
|
| Add vessel form + action | ✅ | IMO number uniqueness check |
|
||||||
|
| Edit vessel form + action | ✅ | |
|
||||||
|
| Deactivate / reactivate vessel | ✅ | |
|
||||||
|
|
||||||
|
### Admin — Account Management
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Account list page | ✅ | `/admin/accounts` |
|
||||||
|
| Add account form + action | ✅ | Code uniqueness check |
|
||||||
|
| Edit account form + action | ✅ | |
|
||||||
|
| Deactivate / reactivate account | ✅ | |
|
||||||
|
|
||||||
|
### Admin — Product Catalogue (`/admin/products`)
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Product list (code, name, last price, last vendor) | ✅ | Last price/vendor read-only — auto-populated on payment |
|
||||||
|
| Create product action | ✅ | Code must be unique |
|
||||||
|
| Toggle active/inactive action | ✅ | |
|
||||||
|
| Add product form (UI) | ✅ | `AdminDialog` modal wired to existing `createProduct` action |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Type Safety
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Discriminated union narrowing in `approval-actions.tsx` | ✅ | Typed as `{ ok: true } \| { error: string } \| undefined`, checked with `"error" in result` |
|
||||||
|
| Union narrowing in `receipt-form.tsx` | ✅ | |
|
||||||
|
| Union narrowing in `new-po-form.tsx` | ✅ | |
|
||||||
|
| Buffer type in `api/files/dev/route.ts` | ✅ | Cast to `Uint8Array` |
|
||||||
|
| New PO fields in Prisma types | ⚠️ | Migration applied; Prisma client types stale — resolve by restarting dev server and running `pnpm db:generate` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
| Item | Status | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Unit: state machine | ✅ | `tests/unit/po-state-machine.test.ts` — 21 tests |
|
||||||
|
| Unit: permissions | ✅ | `tests/unit/permissions.test.ts` — 11 tests |
|
||||||
|
| Unit: utility functions | ✅ | `tests/unit/utils.test.ts` — 17 tests (formatCurrency INR, formatDate, generatePoNumber, status labels/variants) |
|
||||||
|
| Unit: Zod validation schemas | ✅ | `tests/unit/validations.test.ts` — 24 tests (createPoSchema, lineItemSchema, TC fields, defaults) |
|
||||||
|
| Component: PoStatusBadge | ✅ | `tests/unit/po-status-badge.test.tsx` — 17 tests (all 10 statuses) |
|
||||||
|
| Component: LineItemsEditor | ✅ | `tests/unit/po-line-items-editor.test.tsx` — 20 tests (add/remove rows, GST calc, totals, read-only, diff mode) |
|
||||||
|
| Integration: PO creation (S-01, S-02, S-03) | ✅ | `tests/integration/create-po.test.ts` — 9 tests; uses real DB + mocked auth/notifier |
|
||||||
|
| Integration: Approval actions (M-02, M-03, M-04, S-06, S-07) | ✅ | `tests/integration/approval-actions.test.ts` — 14 tests |
|
||||||
|
| Integration: Payment actions (A-01, A-02) | ✅ | `tests/integration/payment-actions.test.ts` — 8 tests |
|
||||||
|
| E2E: authentication flow | ✅ | `tests/e2e/auth.spec.ts` — 6 tests |
|
||||||
|
| E2E: submitter journey (S-01 to S-08) | ✅ | `tests/e2e/submitter-journey.spec.ts` — 8 tests |
|
||||||
|
| E2E: manager approvals (M-01 to M-04) | ✅ | `tests/e2e/manager-approvals.spec.ts` — 9 tests |
|
||||||
|
| E2E: accounts payment (A-01, A-02) | ✅ | `tests/e2e/accounts-payment.spec.ts` — 6 tests |
|
||||||
|
| E2E: PO export (PDF + XLSX) | ✅ | `tests/e2e/po-export.spec.ts` — 8 tests (buttons, endpoints, content, auth) |
|
||||||
|
| Accessibility (axe-core + Playwright) | ❌ | Not yet written |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Category | Total items tracked | ✅ Done | ⚠️ Partial | ❌ Pending |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Infrastructure | 8 | 7 | 0 | 1 |
|
||||||
|
| Auth & Permissions | 5 | 4 | 0 | 1 |
|
||||||
|
| State Machine | 5 | 5 | 0 | 0 |
|
||||||
|
| Email Notifications | 7 | 7 | 0 | 0 |
|
||||||
|
| File Storage | 6 | 6 | 0 | 0 |
|
||||||
|
| Pages & Actions | 83 | 83 | 0 | 0 |
|
||||||
|
| Type Safety | 5 | 4 | 1 | 0 |
|
||||||
|
| Testing | 15 | 14 | 0 | 1 |
|
||||||
|
| **Total** | **134** | **130 (97%)** | **1 (1%)** | **3 (2%)** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Remaining Items
|
||||||
|
|
||||||
|
1. **Prisma client regeneration** — restart dev server, then `pnpm db:generate` (DLL was locked during the last generate; migration is fully applied, no data loss)
|
||||||
|
2. **CI/CD** — GitHub Actions workflow: lint, type-check, test, build on PR; deploy on merge
|
||||||
|
3. **Accessibility tests** — axe-core + Playwright
|
||||||
|
4. **SSO / Azure AD** — credentials-only for v1; open question for v2
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Not in Spec (deferred)
|
||||||
|
|
||||||
|
- SSO / Azure AD login
|
||||||
|
- Discount field on PO
|
||||||
|
- Account Group / Account Code hierarchy (codes currently flat)
|
||||||
|
- Cost centre vs vessel distinction (currently same entity)
|
||||||
|
- Vendor deactivation cascade — warn if vendor has open POs
|
||||||
|
- Approval queue — show VENDOR_ID_PENDING alongside MGR_REVIEW
|
||||||
|
- Vendor required before approval can be granted (currently advisory only)
|
||||||
93
Progress/TODO.md
Normal file
93
Progress/TODO.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
30/04/2026
|
||||||
|
|
||||||
|
- Terms & Conditions (end of PO) [DONE - App implemented 05/05, structured 05/05]
|
||||||
|
- Tax (GST) [DONE - App implemented 05/05]
|
||||||
|
- Currency in INR [DONE - App implemented 05/05]
|
||||||
|
- Discount [PENDING - not yet specced or built]
|
||||||
|
- Cost centre and vessel is same [PENDING SPEC UPDATE]
|
||||||
|
- Account codes are too many. Can be divided in Account Group / Account code [PENDING SPEC UPDATE]
|
||||||
|
- UoM can be drop down [DONE - Spec updated]
|
||||||
|
- Size can be a different box for easy filtering [DONE - Spec updated]
|
||||||
|
- In Edit mode, manager must be allowed to make changes in the line items with different font colour. Previous entry to be made visible with strike off [DONE - App implemented]
|
||||||
|
- Unable to see approved PO from dashboard [DONE - Spec updated]
|
||||||
|
- Unable to see breakup of the expenses approved [DONE - Spec updated]
|
||||||
|
- User dashboard Cannot open old PO [DONE - Spec updated]
|
||||||
|
|
||||||
|
03/05/2026
|
||||||
|
|
||||||
|
- List of Product and product codes needs to be maintained in db along with last known prices (along with vendor)
|
||||||
|
when po is paid, the product db is automatically updated and vendor stored. if vendor is already on list, price updated
|
||||||
|
[DONE - Spec updated]
|
||||||
|
[DONE - App implemented]
|
||||||
|
prisma/schema.prisma — Product model, productId on POLineItem, PRODUCT_PRICE_UPDATED ActionType, Vendor→Product relation
|
||||||
|
lib/permissions.ts — manage_products permission (Admin)
|
||||||
|
payments/actions.ts — markPaid auto-updates Product.lastPrice + lastVendorId; logs PRODUCT_PRICE_UPDATED action
|
||||||
|
admin/products/page.tsx — Admin Product Catalogue list (code, name, description, lastPrice, lastVendor, status)
|
||||||
|
admin/products/actions.ts — createProduct, toggleProductActive server actions
|
||||||
|
sidebar.tsx — Products link in admin nav
|
||||||
|
seed.ts — 4 sample products; PO-2026-00001 line items linked to products
|
||||||
|
[DONE - Migration applied as part of 20260505114211_add_po_export_fields]
|
||||||
|
|
||||||
|
04/05/2026
|
||||||
|
|
||||||
|
- po needs to have delivery date [DONE - App implemented 05/05]
|
||||||
|
- terms and conditions is at the end of PO, set by tech/manning [DONE - App implemented 05/05]
|
||||||
|
- manager can edit any item on po [DONE - App implemented (amber edit mode)]
|
||||||
|
- po needs to have vendor before being approved [PENDING - no enforcement in approval action yet]
|
||||||
|
- export to excel(xlsx) needed for individual PO [DONE - App implemented 05/05]
|
||||||
|
|
||||||
|
05/05/2026
|
||||||
|
|
||||||
|
- PO export (XLSX + PDF) for sample PO PMS/HNR3/056/2026-27 [DONE]
|
||||||
|
Output: Progress/PMS_HNR3_056_2026-27.xlsx — Excel with live formulas
|
||||||
|
Progress/PMS_HNR3_056_2026-27.pdf — PDF matching sample layout
|
||||||
|
Script: generate_po.py (project root) — reusable for any future standalone export
|
||||||
|
|
||||||
|
- App updated: all Sample PO fields + in-app individual PO export [DONE]
|
||||||
|
Schema changes (migration 20260505114211_add_po_export_fields applied):
|
||||||
|
PurchaseOrder += piQuotationNo, piQuotationDate, requisitionNo, requisitionDate,
|
||||||
|
placeOfDelivery, termsAndConditions; currency default USD → INR
|
||||||
|
POLineItem += gstRate (default 0.18 = 18%)
|
||||||
|
Vendor += address, gstin, contactMobile
|
||||||
|
Forms (New PO + Edit PO):
|
||||||
|
+ Quotation Reference section (PI No. + Date)
|
||||||
|
+ Requisition section (Req No. + Date)
|
||||||
|
+ Delivery section (Place of Delivery, pre-filled)
|
||||||
|
+ Terms & Conditions section (pre-filled 7-point T&C, editable)
|
||||||
|
+ GST% per line item (0/5/12/18/28% dropdown, default 18%)
|
||||||
|
+ Taxable / GST / Grand Total breakdown live in editor
|
||||||
|
Vendor admin: address, GSTIN, contact mobile added to add/edit form
|
||||||
|
PO Detail: all new fields displayed; vendor shows address, GSTIN, contacts
|
||||||
|
Export route: GET /api/po/[id]/export?format=pdf|xlsx
|
||||||
|
PDF — HTML print page matching Sample_PO layout (auto-triggers print dialog)
|
||||||
|
XLSX — SheetJS workbook matching Sample_PO column layout
|
||||||
|
Export PDF / Export XLSX buttons added to PO detail header
|
||||||
|
Currency display updated to INR (en-IN locale) throughout
|
||||||
|
|
||||||
|
PENDING: restart dev server → `pnpm db:generate`
|
||||||
|
(Prisma client types are stale — DLL was locked during generate.
|
||||||
|
Both migrations are fully applied; runtime works correctly.)
|
||||||
|
|
||||||
|
05/05/2026 (later) -
|
||||||
|
|
||||||
|
- T&C section broken into structured fields [DONE]
|
||||||
|
Schema: termsAndConditions (free text) replaced by:
|
||||||
|
tcDelivery, tcDispatch, tcInspection, tcTransitInsurance,
|
||||||
|
tcPaymentTerms, tcOthers
|
||||||
|
Migration: structured_tc_fields (applied)
|
||||||
|
Forms: Line 1 — fixed read-only banner (same for all POs)
|
||||||
|
Lines 2–6 — labelled single-line inputs, pre-filled with defaults
|
||||||
|
Line 7 — Others, multiline textarea
|
||||||
|
Detail: Numbered list, each field shown as "LABEL: value"
|
||||||
|
Export: PDF + XLSX both render numbered list with LABEL: value format
|
||||||
|
|
||||||
|
14/05/2026
|
||||||
|
|
||||||
|
- Need to add support for multiple accounts in single PO
|
||||||
|
- Need to rename Vessel to "Cost Center"
|
||||||
|
- need to have the support for multiple contacts at single vendor
|
||||||
|
- need to make items page tabular
|
||||||
|
- need to make vendor details page
|
||||||
|
- need to add new mail notification for paid and approved po
|
||||||
|
- need to ensure accounts notified on approved po
|
||||||
|
- need to make last line of t&c compulsory
|
||||||
BIN
Prototype/20260429_005636.jpg
Normal file
BIN
Prototype/20260429_005636.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2 MiB |
646
Prototype/Pelagia Diagrams.html
Normal file
646
Prototype/Pelagia Diagrams.html
Normal file
|
|
@ -0,0 +1,646 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pelagia Marine Portal — System Diagrams</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #f5f0e8; font-family: 'Caveat', cursive; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
|
||||||
|
/* ─── SVG PRIMITIVES ─────────────────────────────────── */
|
||||||
|
|
||||||
|
function Actor({ x, y, label, sub }) {
|
||||||
|
const lines = label.split('\n');
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<circle cx={x} cy={y} r={11} stroke="#222" strokeWidth={1.8} fill="#fafaf8"/>
|
||||||
|
<line x1={x} y1={y+11} x2={x} y2={y+38} stroke="#222" strokeWidth={1.8}/>
|
||||||
|
<line x1={x-16} y1={y+22} x2={x+16} y2={y+22} stroke="#222" strokeWidth={1.8}/>
|
||||||
|
<line x1={x} y1={y+38} x2={x-14} y2={y+58} stroke="#222" strokeWidth={1.8}/>
|
||||||
|
<line x1={x} y1={y+38} x2={x+14} y2={y+58} stroke="#222" strokeWidth={1.8}/>
|
||||||
|
{lines.map((l,i) => (
|
||||||
|
<text key={i} x={x} y={y+75+i*16} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={13} fontWeight="700" fill="#222">{l}</text>
|
||||||
|
))}
|
||||||
|
{sub && <text x={x} y={y+75+lines.length*16} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={11} fill="#888" fontStyle="italic">{sub}</text>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UC({ cx, cy, label, rx=88, ry=21, color='#fff', stroke='#222' }) {
|
||||||
|
const lines = label.split('\n');
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<ellipse cx={cx} cy={cy} rx={rx} ry={ry} stroke={stroke} strokeWidth={1.8} fill={color}/>
|
||||||
|
{lines.map((l,i) => (
|
||||||
|
<text key={i} x={cx} y={cy + (i - (lines.length-1)/2)*14 + 4}
|
||||||
|
textAnchor="middle" fontFamily="Caveat" fontSize={12} fill="#222">{l}</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Arrow({ x1,y1,x2,y2, dashed, label, color='#555', markerEnd=true }) {
|
||||||
|
const id = `arr-${Math.abs(x1+y1+x2+y2)|0}`;
|
||||||
|
const dx = x2-x1, dy = y2-y1;
|
||||||
|
const mx = (x1+x2)/2, my = (y1+y2)/2;
|
||||||
|
const angle = Math.atan2(dy,dx)*180/Math.PI;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<defs>
|
||||||
|
<marker id={id} markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L0,6 L8,3 z" fill={color}/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<line x1={x1} y1={y1} x2={x2} y2={y2}
|
||||||
|
stroke={color} strokeWidth={1.4}
|
||||||
|
strokeDasharray={dashed ? '5,4' : undefined}
|
||||||
|
markerEnd={markerEnd ? `url(#${id})` : undefined}/>
|
||||||
|
{label && (
|
||||||
|
<text x={mx} y={my-5} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={10} fill={color} fontStyle="italic"
|
||||||
|
transform={`rotate(${Math.abs(angle)>90?angle+180:angle},${mx},${my-5})`}
|
||||||
|
style={{transformBox:'fill-box'}}>{label}</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Conn({ x1,y1,x2,y2, label, card1, card2, color='#444', dashed }) {
|
||||||
|
const mx=(x1+x2)/2, my=(y1+y2)/2;
|
||||||
|
const dx=x2-x1, dy=y2-y1;
|
||||||
|
const len=Math.sqrt(dx*dx+dy*dy);
|
||||||
|
const ux=dx/len, uy=dy/len;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<line x1={x1} y1={y1} x2={x2} y2={y2}
|
||||||
|
stroke={color} strokeWidth={1.5}
|
||||||
|
strokeDasharray={dashed?'5,4':undefined}/>
|
||||||
|
{card1 && <text x={x1+ux*18-uy*10} y={y1+uy*18+ux*10}
|
||||||
|
textAnchor="middle" fontFamily="Space Mono" fontSize={11} fontWeight="700" fill={color}>{card1}</text>}
|
||||||
|
{card2 && <text x={x2-ux*18-uy*10} y={y2-uy*18+ux*10}
|
||||||
|
textAnchor="middle" fontFamily="Space Mono" fontSize={11} fontWeight="700" fill={color}>{card2}</text>}
|
||||||
|
{label && <text x={mx-uy*12} y={my+ux*12}
|
||||||
|
textAnchor="middle" fontFamily="Caveat" fontSize={11} fill={color} fontStyle="italic">{label}</text>}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── ENTITY BOX ─────────────────────────────────────── */
|
||||||
|
function EntityBox({ x, y, name, attrs, w=200, headerColor='#222' }) {
|
||||||
|
const ROW = 19;
|
||||||
|
const HEADER = 28;
|
||||||
|
const h = HEADER + attrs.length * ROW + 8;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect x={x} y={y} width={w} height={h} rx={2}
|
||||||
|
stroke="#222" strokeWidth={2} fill="#fafaf8"/>
|
||||||
|
{/* header */}
|
||||||
|
<rect x={x} y={y} width={w} height={HEADER} rx={2}
|
||||||
|
stroke="#222" strokeWidth={2} fill={headerColor}/>
|
||||||
|
<rect x={x} y={y+HEADER-4} width={w} height={4} fill={headerColor}/>
|
||||||
|
<text x={x+w/2} y={y+19} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={14} fontWeight="700" fill="#fff">{name}</text>
|
||||||
|
{/* divider */}
|
||||||
|
<line x1={x} y1={y+HEADER} x2={x+w} y2={y+HEADER} stroke="#ccc" strokeWidth={1}/>
|
||||||
|
{/* attributes */}
|
||||||
|
{attrs.map((a,i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<text x={x+10} y={y+HEADER+14+i*ROW}
|
||||||
|
fontFamily="Space Mono" fontSize={10}
|
||||||
|
fill={a.pk ? '#3a7cbf' : a.fk ? '#2a8a50' : a.enum ? '#7a4abf' : '#444'}
|
||||||
|
fontWeight={a.pk||a.fk ? '700':'400'}>
|
||||||
|
{a.pk?'PK ':a.fk?'FK ':a.enum?'∑ ':' '}{a.name}
|
||||||
|
</text>
|
||||||
|
{a.type && <text x={x+w-8} y={y+HEADER+14+i*ROW}
|
||||||
|
textAnchor="end" fontFamily="Space Mono" fontSize={9} fill="#aaa">{a.type}</text>}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── USE CASE DIAGRAM ───────────────────────────────── */
|
||||||
|
function UseCaseDiagram() {
|
||||||
|
const W=930, H=780;
|
||||||
|
|
||||||
|
// System boundary
|
||||||
|
const SX=155, SY=38, SW=610, SH=700;
|
||||||
|
|
||||||
|
// Use case columns
|
||||||
|
const LX=290, RX=620;
|
||||||
|
|
||||||
|
// Left use cases [y positions]
|
||||||
|
const lucs = [
|
||||||
|
{ y:100, label:'Login / Authenticate' },
|
||||||
|
{ y:188, label:'Create Purchase Order' },
|
||||||
|
{ y:268, label:'Add Line Items' },
|
||||||
|
{ y:348, label:'Attach Documents' },
|
||||||
|
{ y:432, label:'Submit for Approval' },
|
||||||
|
{ y:515, label:'View & Track My POs' },
|
||||||
|
{ y:598, label:'Edit & Resubmit PO' },
|
||||||
|
{ y:680, label:'View PO History' }, // shared with manager
|
||||||
|
];
|
||||||
|
|
||||||
|
// Right use cases
|
||||||
|
const rucs = [
|
||||||
|
{ y:130, label:'Review Pending\nApprovals' },
|
||||||
|
{ y:218, label:'Approve PO' },
|
||||||
|
{ y:300, label:'Reject PO' },
|
||||||
|
{ y:383, label:'Ask for Edits' },
|
||||||
|
{ y:466, label:'Approve with Note' },
|
||||||
|
{ y:560, label:'View Payment Queue' },
|
||||||
|
{ y:648, label:'Process Payment' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Actor positions [cx, head_y]
|
||||||
|
const actors = {
|
||||||
|
tech: { x:72, y:175 },
|
||||||
|
manning: { x:72, y:370 },
|
||||||
|
admin: { x:72, y:535 },
|
||||||
|
manager: { x:858, y:220 },
|
||||||
|
accounts:{ x:858, y:500 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const la = actors;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ background:'#fafaf8', borderRadius:4 }}>
|
||||||
|
<defs>
|
||||||
|
<marker id="uca" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L0,6 L8,3 z" fill="#555"/>
|
||||||
|
</marker>
|
||||||
|
<marker id="ucb" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L0,6 L8,3 z" fill="#3a7cbf"/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* System boundary */}
|
||||||
|
<rect x={SX} y={SY} width={SW} height={SH} rx={4}
|
||||||
|
stroke="#222" strokeWidth={2.5} fill="#f9f8f5" strokeDasharray="none"/>
|
||||||
|
<text x={SX+SW/2} y={SY-10} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={15} fontWeight="700" fill="#222">
|
||||||
|
Pelagia Marine Portal — Purchasing System
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* ── Left submitter connections ── */}
|
||||||
|
{/* Technical connects to all left UCs */}
|
||||||
|
{lucs.map((uc,i) => (
|
||||||
|
<line key={`t${i}`}
|
||||||
|
x1={la.tech.x+20} y1={la.tech.y+30}
|
||||||
|
x2={LX-88} y2={uc.y}
|
||||||
|
stroke="#bbb" strokeWidth={1.2}/>
|
||||||
|
))}
|
||||||
|
{/* Manning connects to UCs 0–6 (not history) */}
|
||||||
|
{lucs.slice(0,7).map((uc,i) => (
|
||||||
|
<line key={`m${i}`}
|
||||||
|
x1={la.manning.x+20} y1={la.manning.y+30}
|
||||||
|
x2={LX-88} y2={uc.y}
|
||||||
|
stroke="#bbb" strokeWidth={1.2}/>
|
||||||
|
))}
|
||||||
|
{/* Admin connects to all left UCs */}
|
||||||
|
{lucs.map((uc,i) => (
|
||||||
|
<line key={`ad${i}`}
|
||||||
|
x1={la.admin.x+20} y1={la.admin.y+30}
|
||||||
|
x2={LX-88} y2={uc.y}
|
||||||
|
stroke="#bbb" strokeWidth={1.2}/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Manager connections ── */}
|
||||||
|
{rucs.slice(0,5).map((uc,i) => (
|
||||||
|
<line key={`mg${i}`}
|
||||||
|
x1={la.manager.x-20} y1={la.manager.y+30}
|
||||||
|
x2={RX+88} y2={uc.y}
|
||||||
|
stroke="#bbb" strokeWidth={1.2}/>
|
||||||
|
))}
|
||||||
|
{/* Manager also connects to View PO History (left col) */}
|
||||||
|
<line x1={la.manager.x-20} y1={la.manager.y+30} x2={LX+88} y2={680} stroke="#bbb" strokeWidth={1.2}/>
|
||||||
|
|
||||||
|
{/* ── Accounts connections ── */}
|
||||||
|
{rucs.slice(5).map((uc,i) => (
|
||||||
|
<line key={`ac${i}`}
|
||||||
|
x1={la.accounts.x-20} y1={la.accounts.y+30}
|
||||||
|
x2={RX+88} y2={uc.y}
|
||||||
|
stroke="#bbb" strokeWidth={1.2}/>
|
||||||
|
))}
|
||||||
|
{/* Accounts also sees history */}
|
||||||
|
<line x1={la.accounts.x-20} y1={la.accounts.y+30} x2={LX+88} y2={680} stroke="#bbb" strokeWidth={1.2}/>
|
||||||
|
|
||||||
|
{/* ── Include / Extend relationships ── */}
|
||||||
|
{/* Create PO <<includes>> Add Line Items */}
|
||||||
|
<line x1={LX+30} y1={188} x2={LX+30} y2={255}
|
||||||
|
stroke="#3a7cbf" strokeWidth={1.3} strokeDasharray="5,3"
|
||||||
|
markerEnd="url(#ucb)"/>
|
||||||
|
<text x={LX+40} y={228} fontFamily="Caveat" fontSize={10} fill="#3a7cbf" fontStyle="italic">«includes»</text>
|
||||||
|
|
||||||
|
{/* Submit <<extends>> Attach Documents */}
|
||||||
|
<line x1={LX-30} y1={432} x2={LX-30} y2={362}
|
||||||
|
stroke="#3a7cbf" strokeWidth={1.3} strokeDasharray="5,3"
|
||||||
|
markerEnd="url(#ucb)"/>
|
||||||
|
<text x={LX-110} y={400} fontFamily="Caveat" fontSize={10} fill="#3a7cbf" fontStyle="italic">«extends»</text>
|
||||||
|
|
||||||
|
{/* Approve PO <<extends>> Approve with Note */}
|
||||||
|
<line x1={RX+30} y1={466} x2={RX+30} y2={232}
|
||||||
|
stroke="#3a7cbf" strokeWidth={1.3} strokeDasharray="5,3"
|
||||||
|
markerEnd="url(#ucb)"/>
|
||||||
|
<text x={RX+38} y={356} fontFamily="Caveat" fontSize={10} fill="#3a7cbf" fontStyle="italic">«extends»</text>
|
||||||
|
|
||||||
|
{/* Edit & Resubmit <<extends>> Submit */}
|
||||||
|
<line x1={LX} y1={598} x2={LX} y2={446}
|
||||||
|
stroke="#3a7cbf" strokeWidth={1.3} strokeDasharray="5,3"
|
||||||
|
markerEnd="url(#ucb)"/>
|
||||||
|
<text x={LX+8} y={525} fontFamily="Caveat" fontSize={10} fill="#3a7cbf" fontStyle="italic">«extends»</text>
|
||||||
|
|
||||||
|
{/* ── Use Cases ── */}
|
||||||
|
{lucs.map((uc,i) => (
|
||||||
|
<UC key={i} cx={LX} cy={uc.y} label={uc.label} rx={92}
|
||||||
|
color={i===7 ? '#d4e9f7' : '#fff'}
|
||||||
|
stroke={i===7 ? '#3a7cbf' : '#222'}/>
|
||||||
|
))}
|
||||||
|
{rucs.map((uc,i) => (
|
||||||
|
<UC key={i} cx={RX} cy={uc.y} label={uc.label} rx={92}/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ── Actors ── */}
|
||||||
|
<Actor x={la.tech.x} y={la.tech.y} label={"Technical\nOfficer"} />
|
||||||
|
<Actor x={la.manning.x} y={la.manning.y} label={"Manning\nOfficer"} />
|
||||||
|
<Actor x={la.admin.x} y={la.admin.y} label="Admin" />
|
||||||
|
<Actor x={la.manager.x} y={la.manager.y} label="Manager" />
|
||||||
|
<Actor x={la.accounts.x} y={la.accounts.y} label="Accounts" />
|
||||||
|
|
||||||
|
{/* ── Legend ── */}
|
||||||
|
<g transform="translate(158,710)">
|
||||||
|
<line x1={0} y1={8} x2={30} y2={8} stroke="#bbb" strokeWidth={1.5}/>
|
||||||
|
<text x={36} y={12} fontFamily="Caveat" fontSize={11} fill="#888">Association</text>
|
||||||
|
<line x1={110} y1={8} x2={140} y2={8} stroke="#3a7cbf" strokeWidth={1.3} strokeDasharray="5,3"/>
|
||||||
|
<text x={146} y={12} fontFamily="Caveat" fontSize={11} fill="#3a7cbf">«includes» / «extends»</text>
|
||||||
|
<ellipse cx={330} cy={8} rx={22} ry={9} stroke="#222" strokeWidth={1.5} fill="#d4e9f7"/>
|
||||||
|
<text x={360} y={12} fontFamily="Caveat" fontSize={11} fill="#888">Shared use case</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── ER DIAGRAM ─────────────────────────────────────── */
|
||||||
|
function ERDiagram() {
|
||||||
|
const W=1130, H=890;
|
||||||
|
|
||||||
|
const entities = {
|
||||||
|
user: {
|
||||||
|
x:15, y:40, w:185,
|
||||||
|
name:'USER', color:'#1a3a5c',
|
||||||
|
attrs:[
|
||||||
|
{name:'user_id', type:'uuid', pk:true},
|
||||||
|
{name:'employee_id', type:'varchar'},
|
||||||
|
{name:'full_name', type:'varchar'},
|
||||||
|
{name:'email', type:'varchar'},
|
||||||
|
{name:'role', type:'enum', enum:true},
|
||||||
|
{name:'department', type:'varchar'},
|
||||||
|
{name:'created_at', type:'timestamp'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
vessel: {
|
||||||
|
x:220, y:40, w:175,
|
||||||
|
name:'VESSEL', color:'#1a4a3a',
|
||||||
|
attrs:[
|
||||||
|
{name:'vessel_id', type:'uuid', pk:true},
|
||||||
|
{name:'name', type:'varchar'},
|
||||||
|
{name:'vessel_type', type:'varchar'},
|
||||||
|
{name:'imo_number', type:'varchar'},
|
||||||
|
{name:'flag_state', type:'varchar'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
account: {
|
||||||
|
x:415, y:40, w:185,
|
||||||
|
name:'ACCOUNT', color:'#3a1a5c',
|
||||||
|
attrs:[
|
||||||
|
{name:'account_id', type:'uuid', pk:true},
|
||||||
|
{name:'account_code', type:'varchar'},
|
||||||
|
{name:'department', type:'varchar'},
|
||||||
|
{name:'annual_budget', type:'decimal'},
|
||||||
|
{name:'budget_used', type:'decimal'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
vendor: {
|
||||||
|
x:620, y:40, w:185,
|
||||||
|
name:'VENDOR', color:'#5c2a5c',
|
||||||
|
attrs:[
|
||||||
|
{name:'vendor_id', type:'uuid', pk:true},
|
||||||
|
{name:'vendor_name', type:'varchar'},
|
||||||
|
{name:'contact_name', type:'varchar'},
|
||||||
|
{name:'email', type:'varchar'},
|
||||||
|
{name:'phone', type:'varchar'},
|
||||||
|
{name:'country', type:'varchar'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
notification: {
|
||||||
|
x:825, y:40, w:200,
|
||||||
|
name:'NOTIFICATION', color:'#5c3a1a',
|
||||||
|
attrs:[
|
||||||
|
{name:'notif_id', type:'uuid', pk:true},
|
||||||
|
{name:'recipient_id', type:'uuid', fk:true},
|
||||||
|
{name:'po_id', type:'uuid', fk:true},
|
||||||
|
{name:'message', type:'text'},
|
||||||
|
{name:'is_read', type:'boolean'},
|
||||||
|
{name:'created_at', type:'timestamp'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
po: {
|
||||||
|
x:290, y:315, w:265,
|
||||||
|
name:'PURCHASE_ORDER', color:'#222',
|
||||||
|
attrs:[
|
||||||
|
{name:'po_id', type:'uuid', pk:true},
|
||||||
|
{name:'po_number', type:'varchar'},
|
||||||
|
{name:'status', type:'enum', enum:true},
|
||||||
|
{name:'priority', type:'enum', enum:true},
|
||||||
|
{name:'total_cost', type:'decimal'},
|
||||||
|
{name:'notes', type:'text'},
|
||||||
|
{name:'vessel_id', type:'uuid', fk:true},
|
||||||
|
{name:'account_id', type:'uuid', fk:true},
|
||||||
|
{name:'vendor_id', type:'uuid', fk:true},
|
||||||
|
{name:'submitted_by', type:'uuid', fk:true},
|
||||||
|
{name:'reviewed_by', type:'uuid', fk:true},
|
||||||
|
{name:'paid_by', type:'uuid', fk:true},
|
||||||
|
{name:'submitted_at', type:'timestamp'},
|
||||||
|
{name:'created_at', type:'timestamp'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
lineitem: {
|
||||||
|
x:15, y:650, w:190,
|
||||||
|
name:'PO_LINE_ITEM', color:'#2a4a6a',
|
||||||
|
attrs:[
|
||||||
|
{name:'line_item_id', type:'uuid', pk:true},
|
||||||
|
{name:'po_id', type:'uuid', fk:true},
|
||||||
|
{name:'item_name', type:'varchar'},
|
||||||
|
{name:'item_code', type:'varchar'},
|
||||||
|
{name:'quantity', type:'int'},
|
||||||
|
{name:'unit_cost', type:'decimal'},
|
||||||
|
{name:'total_cost', type:'decimal'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
x:225, y:650, w:205,
|
||||||
|
name:'PO_ACTION', color:'#4a2a2a',
|
||||||
|
attrs:[
|
||||||
|
{name:'action_id', type:'uuid', pk:true},
|
||||||
|
{name:'po_id', type:'uuid', fk:true},
|
||||||
|
{name:'actor_id', type:'uuid', fk:true},
|
||||||
|
{name:'action_type', type:'enum', enum:true},
|
||||||
|
{name:'comment', type:'text'},
|
||||||
|
{name:'created_at', type:'timestamp'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
x:450, y:650, w:185,
|
||||||
|
name:'PO_DOCUMENT', color:'#2a4a2a',
|
||||||
|
attrs:[
|
||||||
|
{name:'document_id', type:'uuid', pk:true},
|
||||||
|
{name:'po_id', type:'uuid', fk:true},
|
||||||
|
{name:'filename', type:'varchar'},
|
||||||
|
{name:'file_url', type:'varchar'},
|
||||||
|
{name:'uploaded_by', type:'uuid', fk:true},
|
||||||
|
{name:'uploaded_at', type:'timestamp'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
receipt: {
|
||||||
|
x:655, y:650, w:200,
|
||||||
|
name:'RECEIPT', color:'#2a4a5c',
|
||||||
|
attrs:[
|
||||||
|
{name:'receipt_id', type:'uuid', pk:true},
|
||||||
|
{name:'po_id', type:'uuid', fk:true},
|
||||||
|
{name:'file_url', type:'varchar'},
|
||||||
|
{name:'amount_paid', type:'decimal'},
|
||||||
|
{name:'uploaded_by', type:'uuid', fk:true},
|
||||||
|
{name:'confirmed_by', type:'uuid', fk:true},
|
||||||
|
{name:'confirmed_at', type:'timestamp'},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const pt = (e, side) => {
|
||||||
|
const ROW=19, HEADER=28;
|
||||||
|
const h = HEADER + e.attrs.length * ROW + 8;
|
||||||
|
if (side==='top') return [e.x+e.w/2, e.y];
|
||||||
|
if (side==='bottom') return [e.x+e.w/2, e.y+h];
|
||||||
|
if (side==='left') return [e.x, e.y+h/2];
|
||||||
|
if (side==='right') return [e.x+e.w, e.y+h/2];
|
||||||
|
return [e.x+e.w/2, e.y+h/2];
|
||||||
|
};
|
||||||
|
|
||||||
|
const E = entities;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ background:'#fafaf8', borderRadius:4 }}>
|
||||||
|
|
||||||
|
{/* USER → PO (submitted_by) */}
|
||||||
|
<Conn x1={pt(E.user,'bottom')[0]-10} y1={pt(E.user,'bottom')[1]}
|
||||||
|
x2={pt(E.po,'left')[0]} y2={pt(E.po,'left')[1]-45}
|
||||||
|
card1="1" card2="N" label="submits" color="#1a3a5c"/>
|
||||||
|
{/* USER → PO (reviewed_by) */}
|
||||||
|
<Conn x1={pt(E.user,'bottom')[0]+10} y1={pt(E.user,'bottom')[1]}
|
||||||
|
x2={pt(E.po,'left')[0]} y2={pt(E.po,'left')[1]}
|
||||||
|
card1="1" card2="N" label="reviews" color="#c8971a"/>
|
||||||
|
{/* USER → PO (paid_by) */}
|
||||||
|
<Conn x1={pt(E.user,'right')[0]} y1={pt(E.user,'right')[1]+20}
|
||||||
|
x2={pt(E.po,'left')[0]} y2={pt(E.po,'left')[1]+45}
|
||||||
|
card1="1" card2="N" label="pays" color="#2a8a50"/>
|
||||||
|
|
||||||
|
{/* VESSEL → PO */}
|
||||||
|
<Conn x1={pt(E.vessel,'bottom')[0]} y1={pt(E.vessel,'bottom')[1]}
|
||||||
|
x2={pt(E.po,'top')[0]-50} y2={pt(E.po,'top')[1]}
|
||||||
|
card1="1" card2="N" label="assigned to" color="#1a4a3a"/>
|
||||||
|
{/* ACCOUNT → PO */}
|
||||||
|
<Conn x1={pt(E.account,'bottom')[0]} y1={pt(E.account,'bottom')[1]}
|
||||||
|
x2={pt(E.po,'top')[0]} y2={pt(E.po,'top')[1]}
|
||||||
|
card1="1" card2="N" label="charged to" color="#3a1a5c"/>
|
||||||
|
{/* VENDOR → PO (NEW) */}
|
||||||
|
<Conn x1={pt(E.vendor,'bottom')[0]} y1={pt(E.vendor,'bottom')[1]}
|
||||||
|
x2={pt(E.po,'top')[0]+55} y2={pt(E.po,'top')[1]}
|
||||||
|
card1="1" card2="N" label="supplies" color="#5c2a5c"/>
|
||||||
|
|
||||||
|
{/* NOTIFICATION → USER */}
|
||||||
|
<Conn x1={pt(E.notification,'left')[0]} y1={pt(E.notification,'left')[1]-10}
|
||||||
|
x2={pt(E.user,'right')[0]} y2={pt(E.user,'right')[1]-10}
|
||||||
|
card1="N" card2="1" label="sent to" color="#5c3a1a" dashed/>
|
||||||
|
{/* NOTIFICATION → PO */}
|
||||||
|
<Conn x1={pt(E.notification,'bottom')[0]} y1={pt(E.notification,'bottom')[1]}
|
||||||
|
x2={pt(E.po,'right')[0]} y2={pt(E.po,'right')[1]-20}
|
||||||
|
card1="N" card2="1" label="about" color="#5c3a1a" dashed/>
|
||||||
|
|
||||||
|
{/* PO → PO_LINE_ITEM */}
|
||||||
|
<Conn x1={pt(E.po,'bottom')[0]-50} y1={pt(E.po,'bottom')[1]}
|
||||||
|
x2={pt(E.lineitem,'top')[0]} y2={pt(E.lineitem,'top')[1]}
|
||||||
|
card1="1" card2="N" label="has" color="#2a4a6a"/>
|
||||||
|
{/* PO → PO_ACTION */}
|
||||||
|
<Conn x1={pt(E.po,'bottom')[0]-10} y1={pt(E.po,'bottom')[1]}
|
||||||
|
x2={pt(E.action,'top')[0]} y2={pt(E.action,'top')[1]}
|
||||||
|
card1="1" card2="N" label="audit trail" color="#4a2a2a"/>
|
||||||
|
{/* PO → PO_DOCUMENT */}
|
||||||
|
<Conn x1={pt(E.po,'bottom')[0]+20} y1={pt(E.po,'bottom')[1]}
|
||||||
|
x2={pt(E.document,'top')[0]} y2={pt(E.document,'top')[1]}
|
||||||
|
card1="1" card2="N" label="attaches" color="#2a4a2a"/>
|
||||||
|
{/* PO → RECEIPT (NEW) */}
|
||||||
|
<Conn x1={pt(E.po,'bottom')[0]+55} y1={pt(E.po,'bottom')[1]}
|
||||||
|
x2={pt(E.receipt,'top')[0]} y2={pt(E.receipt,'top')[1]}
|
||||||
|
card1="1" card2="1" label="has receipt" color="#2a4a5c"/>
|
||||||
|
|
||||||
|
{/* USER → PO_ACTION */}
|
||||||
|
<Conn x1={pt(E.user,'bottom')[0]-20} y1={pt(E.user,'bottom')[1]}
|
||||||
|
x2={pt(E.action,'left')[0]} y2={pt(E.action,'left')[1]}
|
||||||
|
card1="1" card2="N" label="performs" color="#888" dashed/>
|
||||||
|
{/* USER → PO_DOCUMENT */}
|
||||||
|
<Conn x1={pt(E.user,'right')[0]} y1={pt(E.user,'right')[1]+40}
|
||||||
|
x2={pt(E.document,'top')[0]-20} y2={pt(E.document,'top')[1]}
|
||||||
|
card1="1" card2="N" label="uploads" color="#888" dashed/>
|
||||||
|
{/* USER → RECEIPT (confirmed_by) (NEW) */}
|
||||||
|
<Conn x1={pt(E.user,'right')[0]} y1={pt(E.user,'right')[1]+60}
|
||||||
|
x2={pt(E.receipt,'left')[0]} y2={pt(E.receipt,'left')[1]}
|
||||||
|
card1="1" card2="N" label="confirms" color="#2a4a5c" dashed/>
|
||||||
|
|
||||||
|
{/* ── Entities ── */}
|
||||||
|
{Object.values(entities).map(e => (
|
||||||
|
<EntityBox key={e.name} x={e.x} y={e.y} w={e.w}
|
||||||
|
name={e.name} attrs={e.attrs} headerColor={e.color}/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* NEW badges */}
|
||||||
|
{[{x:620+92, y:30},{x:655+100, y:640}].map((b,i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={b.x-18} y={b.y-8} width={36} height={16} rx={8} fill="#5c2a5c"/>
|
||||||
|
<text x={b.x} y={b.y+4} textAnchor="middle" fontFamily="Caveat"
|
||||||
|
fontSize={10} fontWeight="700" fill="#fff">NEW</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{/* vendor_id FK badge on PO */}
|
||||||
|
<rect x={pt(E.po,'right')[0]+4} y={pt(E.po,'right')[1]-50}
|
||||||
|
width={60} height={14} rx={7} fill="#5c2a5c" fillOpacity={0.15}
|
||||||
|
stroke="#5c2a5c" strokeWidth={1}/>
|
||||||
|
<text x={pt(E.po,'right')[0]+34} y={pt(E.po,'right')[1]-40}
|
||||||
|
textAnchor="middle" fontFamily="Caveat" fontSize={9} fill="#5c2a5c">+vendor_id</text>
|
||||||
|
|
||||||
|
{/* ── Legend ── */}
|
||||||
|
<g transform="translate(15,850)">
|
||||||
|
<rect x={0} y={-14} width={1100} height={30} rx={2}
|
||||||
|
fill="#f0ede6" stroke="#ccc" strokeWidth={1}/>
|
||||||
|
<line x1={8} y1={2} x2={40} y2={2} stroke="#444" strokeWidth={1.5}/>
|
||||||
|
<text x={46} y={6} fontFamily="Caveat" fontSize={11} fill="#555">Relationship</text>
|
||||||
|
<text x={135} y={6} fontFamily="Space Mono" fontSize={10} fontWeight="700" fill="#3a7cbf">PK</text>
|
||||||
|
<text x={153} y={6} fontFamily="Caveat" fontSize={11} fill="#555">Primary Key</text>
|
||||||
|
<text x={255} y={6} fontFamily="Space Mono" fontSize={10} fontWeight="700" fill="#2a8a50">FK</text>
|
||||||
|
<text x={273} y={6} fontFamily="Caveat" fontSize={11} fill="#555">Foreign Key</text>
|
||||||
|
<text x={372} y={6} fontFamily="Space Mono" fontSize={10} fontWeight="700" fill="#7a4abf">∑</text>
|
||||||
|
<text x={386} y={6} fontFamily="Caveat" fontSize={11} fill="#555">Enum</text>
|
||||||
|
<text x={440} y={6} fontFamily="Space Mono" fontSize={10} fontWeight="700" fill="#444">1/N</text>
|
||||||
|
<text x={458} y={6} fontFamily="Caveat" fontSize={11} fill="#555">Cardinality</text>
|
||||||
|
<line x1={545} y1={2} x2={577} y2={2} stroke="#888" strokeWidth={1.5} strokeDasharray="5,3"/>
|
||||||
|
<text x={583} y={6} fontFamily="Caveat" fontSize={11} fill="#555">Indirect ref</text>
|
||||||
|
<rect x={670} y={-7} width={32} height={16} rx={8} fill="#5c2a5c"/>
|
||||||
|
<text x={686} y={5} textAnchor="middle" fontFamily="Caveat" fontSize={10}
|
||||||
|
fontWeight="700" fill="#fff">NEW</text>
|
||||||
|
<text x={708} y={6} fontFamily="Caveat" fontSize={11} fill="#555">Added in v2 (Vendor, Receipt)</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── STATUS ENUM REFERENCE ─────────────────────────── */
|
||||||
|
function EnumRef() {
|
||||||
|
return (
|
||||||
|
<svg width={440} height={200} viewBox="0 0 440 200" style={{ background:'#fafaf8', borderRadius:4 }}>
|
||||||
|
<text x={220} y={22} textAnchor="middle" fontFamily="Caveat" fontSize={15} fontWeight="700" fill="#222">
|
||||||
|
PURCHASE_ORDER.status — Enum Values
|
||||||
|
</text>
|
||||||
|
|
||||||
|
{/* Flow arrow */}
|
||||||
|
{[
|
||||||
|
{ val:'draft', label:'Draft', x:30, color:'#888' },
|
||||||
|
{ val:'submitted', label:'Submitted', x:100, color:'#3a7cbf' },
|
||||||
|
{ val:'mgr_review', label:'Mgr. Review', x:185, color:'#c8971a' },
|
||||||
|
{ val:'edits_requested', label:'Edits Requested', x:275, color:'#c03030' },
|
||||||
|
{ val:'rejected', label:'Rejected', x:365, color:'#c03030' },
|
||||||
|
].map((s,i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={s.x} y={45} width={65} height={26} rx={3}
|
||||||
|
fill={s.color} stroke={s.color} strokeWidth={1.5}/>
|
||||||
|
<text x={s.x+32} y={62} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={11} fill="#fff">{s.label}</text>
|
||||||
|
{i<4 && <text x={s.x+72} y={62} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={14} fill="#aaa">→</text>}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Second row: the approval chain */}
|
||||||
|
{[
|
||||||
|
{ val:'submitted', label:'Submitted', x:30, color:'#3a7cbf' },
|
||||||
|
{ val:'mgr_review', label:'Mgr. Review', x:110, color:'#c8971a' },
|
||||||
|
{ val:'approved', label:'Mgr. Approved', x:200, color:'#2a8a50' },
|
||||||
|
{ val:'payment', label:'Awaiting Pmt', x:300, color:'#3a7cbf' },
|
||||||
|
{ val:'paid', label:'Paid ✓', x:390, color:'#1a5a2a' },
|
||||||
|
].map((s,i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={s.x} y={100} width={72} height={26} rx={3}
|
||||||
|
fill={s.color} stroke={s.color} strokeWidth={1.5}/>
|
||||||
|
<text x={s.x+36} y={117} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={11} fill="#fff">{s.label}</text>
|
||||||
|
{i<4 && <text x={s.x+78} y={117} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={14} fill="#aaa">→</text>}
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<text x={14} y={42} fontFamily="Caveat" fontSize={11} fill="#888" fontStyle="italic">rejection / edit path:</text>
|
||||||
|
<text x={14} y={97} fontFamily="Caveat" fontSize={11} fill="#888" fontStyle="italic">approval path:</text>
|
||||||
|
|
||||||
|
{/* PO_ACTION.action_type enum */}
|
||||||
|
<text x={14} y={155} fontFamily="Caveat" fontSize={12} fontWeight="700" fill="#555">PO_ACTION.action_type:</text>
|
||||||
|
{['submitted','approved','rejected','edits_requested','approved_with_note','paid'].map((v,i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={14+i*72} y={162} width={68} height={22} rx={2}
|
||||||
|
fill="#f0ede6" stroke="#ccc" strokeWidth={1.2}/>
|
||||||
|
<text x={14+i*72+34} y={177} textAnchor="middle"
|
||||||
|
fontFamily="Space Mono" fontSize={8} fill="#555">{v}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── APP ────────────────────────────────────────────── */
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<DesignCanvas title="Pelagia Marine Portal — System Diagrams">
|
||||||
|
|
||||||
|
<DCSection id="s1" title="Use Case Diagram — Purchasing Module">
|
||||||
|
<DCArtboard id="ucd" label="Use Case Diagram" width={930} height={780}>
|
||||||
|
<UseCaseDiagram/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
|
||||||
|
<DCSection id="s2" title="Entity Relationship Diagram">
|
||||||
|
<DCArtboard id="erd" label="ER Diagram (v2 — VENDOR + RECEIPT added)" width={1130} height={890}>
|
||||||
|
<ERDiagram/>
|
||||||
|
</DCArtboard>
|
||||||
|
<DCArtboard id="enums" label="Status Enum Reference" width={440} height={200}>
|
||||||
|
<EnumRef/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
|
||||||
|
</DesignCanvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1167
Prototype/Pelagia Portal Wireframes.html
Normal file
1167
Prototype/Pelagia Portal Wireframes.html
Normal file
File diff suppressed because it is too large
Load diff
656
Prototype/Pelagia System Diagrams v2.html
Normal file
656
Prototype/Pelagia System Diagrams v2.html
Normal file
|
|
@ -0,0 +1,656 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Pelagia Marine — System Diagrams v2</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Caveat:wght@400;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
<script type="text/babel" src="design-canvas.jsx"></script>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: #f5f0e8; font-family: 'Caveat', cursive; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="text/babel">
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════
|
||||||
|
DIAGRAM 1 — PO STATE MACHINE
|
||||||
|
══════════════════════════════════════════════════════ */
|
||||||
|
function StateMachineDiagram() {
|
||||||
|
const W = 960, H = 800;
|
||||||
|
|
||||||
|
// State colors
|
||||||
|
const C = {
|
||||||
|
draft: '#7a7a7a',
|
||||||
|
submitted: '#2a6ab5',
|
||||||
|
mgr: '#c8871a',
|
||||||
|
vendor: '#b87010',
|
||||||
|
edits: '#b83030',
|
||||||
|
approved: '#1a7a48',
|
||||||
|
rejected: '#8a1818',
|
||||||
|
payment: '#1a4abf',
|
||||||
|
paid: '#155c35',
|
||||||
|
closed: '#1a1a2e',
|
||||||
|
};
|
||||||
|
|
||||||
|
// State box helper (cx, cy = center)
|
||||||
|
function SBox({ cx, cy, label, color, w=178, h=36, terminal=false }) {
|
||||||
|
const lines = label.split('\n');
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
{terminal && <ellipse cx={cx} cy={cy} rx={w/2+6} ry={h/2+6}
|
||||||
|
fill="none" stroke={color} strokeWidth={2} strokeDasharray="4,3"/>}
|
||||||
|
<rect x={cx-w/2} y={cy-h/2} width={w} height={h} rx={h/2}
|
||||||
|
fill={color} stroke="rgba(0,0,0,0.25)" strokeWidth={1.5}/>
|
||||||
|
{lines.map((l,i) => (
|
||||||
|
<text key={i} x={cx} y={cy + (i-(lines.length-1)/2)*15 + 4}
|
||||||
|
textAnchor="middle" fontFamily="Space Mono" fontSize={10.5}
|
||||||
|
fontWeight="700" fill="#fff">{l}</text>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow marker defs
|
||||||
|
const mkr = (id, color) => (
|
||||||
|
<marker key={id} id={id} markerWidth="8" markerHeight="8"
|
||||||
|
refX="6" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L0,6 L8,3 z" fill={color}/>
|
||||||
|
</marker>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Arrow path with label and optional notification badge
|
||||||
|
function Arr({ d, label, labelX, labelY, color='#555', notif, notifX, notifY, dashed }) {
|
||||||
|
const id = `m${btoa(d).slice(0,8).replace(/[^a-zA-Z0-9]/g,'')}`;
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<defs>{mkr(id, color)}</defs>
|
||||||
|
<path d={d} fill="none" stroke={color} strokeWidth={1.6}
|
||||||
|
strokeDasharray={dashed ? '5,3' : undefined}
|
||||||
|
markerEnd={`url(#${id})`}/>
|
||||||
|
{label && <text x={labelX} y={labelY} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={11} fill={color} fontStyle="italic">{label}</text>}
|
||||||
|
{notif && (
|
||||||
|
<g transform={`translate(${notifX},${notifY})`}>
|
||||||
|
<rect x={-24} y={-9} width={48} height={18} rx={9}
|
||||||
|
fill={color} opacity={0.15} stroke={color} strokeWidth={1}/>
|
||||||
|
<text x={0} y={4} textAnchor="middle" fontSize={9}
|
||||||
|
fontFamily="Caveat" fill={color} fontWeight="700">✉ {notif}</text>
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Envelope icon
|
||||||
|
function Env({ x, y, to, color }) {
|
||||||
|
return (
|
||||||
|
<g transform={`translate(${x},${y})`}>
|
||||||
|
<rect x={-28} y={-11} width={56} height={20} rx={10}
|
||||||
|
fill={color} fillOpacity={0.12} stroke={color} strokeWidth={1.2}/>
|
||||||
|
<text x={0} y={4} textAnchor="middle" fontFamily="Caveat"
|
||||||
|
fontSize={10} fill={color} fontWeight="700">✉ {to}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ background:'#fafaf8', borderRadius:4 }}>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<text x={W/2} y={22} textAnchor="middle" fontFamily="Caveat"
|
||||||
|
fontSize={16} fontWeight="700" fill="#222">Purchase Order — State Machine</text>
|
||||||
|
|
||||||
|
{/* ── ARROWS (drawn behind states) ── */}
|
||||||
|
|
||||||
|
{/* draft → submitted */}
|
||||||
|
<Arr d="M470,73 L470,128" color={C.submitted} label="User submits" labelX={520} labelY={104}/>
|
||||||
|
<Env x={590} y={100} to="User" color={C.submitted}/>
|
||||||
|
|
||||||
|
{/* submitted → mgr_review */}
|
||||||
|
<Arr d="M470,164 L470,232" color={C.mgr} label="Auto-routed" labelX={524} labelY={201}/>
|
||||||
|
<Env x={590} y={198} to="Manager" color={C.mgr}/>
|
||||||
|
|
||||||
|
{/* mgr_review → vendor_pending (right side, no vendor ID) */}
|
||||||
|
<Arr d="M559,253 C650,253 760,295 760,318"
|
||||||
|
color={C.vendor} label="No Vendor ID" labelX={682} labelY={273}/>
|
||||||
|
|
||||||
|
{/* vendor_pending → mgr_review (back, slightly offset) */}
|
||||||
|
<Arr d="M740,318 C700,270 560,243 558,247"
|
||||||
|
color={C.vendor} dashed={true} label="ID provided" labelX={660} labelY={262}/>
|
||||||
|
<Env x={760} y={255} to="Manager" color={C.vendor}/>
|
||||||
|
|
||||||
|
{/* mgr_review → edits_requested (left side) */}
|
||||||
|
<Arr d="M381,255 C270,255 168,300 168,317"
|
||||||
|
color={C.edits} label="Ask for Edits" labelX={258} labelY={270}/>
|
||||||
|
|
||||||
|
{/* edits_requested → submitted (back up, far left curve) */}
|
||||||
|
<Arr d="M100,335 C40,335 40,148 383,148"
|
||||||
|
color={C.edits} dashed={true} label="Resubmit" labelX={48} labelY={242}/>
|
||||||
|
<Env x={52} y={195} to="Manager" color={C.edits}/>
|
||||||
|
|
||||||
|
{/* mgr_review → approved (straight down) */}
|
||||||
|
<Arr d="M470,268 L470,423" color={C.approved}
|
||||||
|
label="Approve / Approve+Note" labelX={536} labelY={349}/>
|
||||||
|
<Env x={620} y={343} to="User + Accounts" color={C.approved}/>
|
||||||
|
|
||||||
|
{/* mgr_review → rejected (right, same y as approved) */}
|
||||||
|
<Arr d="M557,262 C680,280 760,395 760,423"
|
||||||
|
color={C.rejected} label="Reject" labelX={684} labelY={330}/>
|
||||||
|
<Env x={840} y={328} to="User" color={C.rejected}/>
|
||||||
|
|
||||||
|
{/* approved → payment */}
|
||||||
|
<Arr d="M470,459 L470,513" color={C.payment}
|
||||||
|
label="Accounts notified (PO attached)" labelX={356} labelY={490}/>
|
||||||
|
|
||||||
|
{/* payment → paid */}
|
||||||
|
<Arr d="M470,549 L470,603" color={C.paid}
|
||||||
|
label="Accounts marks paid" labelX={358} labelY={579}/>
|
||||||
|
<Env x={240} y={578} to="User" color={C.paid}/>
|
||||||
|
|
||||||
|
{/* paid → closed */}
|
||||||
|
<Arr d="M470,639 L470,683" color={C.closed}
|
||||||
|
label="User confirms receipt" labelX={360} labelY={665}/>
|
||||||
|
|
||||||
|
{/* ── STATES (drawn on top) ── */}
|
||||||
|
<SBox cx={470} cy={55} label="DRAFT" color={C.draft}/>
|
||||||
|
<SBox cx={470} cy={146} label="SUBMITTED" color={C.submitted}/>
|
||||||
|
<SBox cx={470} cy={250} label="MGR_REVIEW" color={C.mgr}/>
|
||||||
|
<SBox cx={760} cy={335} label="VENDOR_ID\nPENDING" color={C.vendor} w={160}/>
|
||||||
|
<SBox cx={168} cy={335} label="EDITS\nREQUESTED" color={C.edits} w={148}/>
|
||||||
|
<SBox cx={470} cy={441} label="MGR_APPROVED" color={C.approved}/>
|
||||||
|
<SBox cx={760} cy={441} label="REJECTED" color={C.rejected} terminal={true} w={148}/>
|
||||||
|
<SBox cx={470} cy={531} label="SENT_FOR_PAYMENT" color={C.payment}/>
|
||||||
|
<SBox cx={470} cy={621} label="PAID_DELIVERED" color={C.paid}/>
|
||||||
|
<SBox cx={470} cy={701} label="CLOSED" color={C.closed} terminal={true}/>
|
||||||
|
|
||||||
|
{/* Notes on rejected */}
|
||||||
|
<text x={845} y={458} fontFamily="Caveat" fontSize={11} fill={C.rejected} fontStyle="italic">saved to rejected DB</text>
|
||||||
|
|
||||||
|
{/* Edits notification */}
|
||||||
|
<Env x={250} y={335} to="User" color={C.edits}/>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<g transform="translate(18,760)">
|
||||||
|
<rect x={0} y={-14} width={920} height={28} rx={2} fill="#eee" stroke="#ccc" strokeWidth={1}/>
|
||||||
|
{[
|
||||||
|
{ color:C.draft, label:'Draft — not yet submitted' },
|
||||||
|
{ color:C.mgr, label:'Under manager review' },
|
||||||
|
{ color:C.approved, label:'Approval / payment path' },
|
||||||
|
{ color:C.edits, label:'Returned for changes' },
|
||||||
|
{ color:C.rejected, label:'Terminal — rejected' },
|
||||||
|
{ color:C.closed, label:'Terminal — complete' },
|
||||||
|
].map((l,i) => (
|
||||||
|
<g key={i} transform={`translate(${i*155+10},0)`}>
|
||||||
|
<rect x={0} y={-7} width={14} height={14} rx={7} fill={l.color}/>
|
||||||
|
<text x={20} y={5} fontFamily="Caveat" fontSize={11} fill="#555">{l.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
|
||||||
|
{/* Envelope legend */}
|
||||||
|
<text x={W-12} y={22} textAnchor="end" fontFamily="Caveat"
|
||||||
|
fontSize={11} fill="#888" fontStyle="italic">✉ = email notification triggered</text>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════
|
||||||
|
DIAGRAM 2 — NOTIFICATION FLOW (swim lanes)
|
||||||
|
══════════════════════════════════════════════════════ */
|
||||||
|
function NotificationFlowDiagram() {
|
||||||
|
const W = 900, H = 660;
|
||||||
|
const LANES = [
|
||||||
|
{ label:'Submitter\n(Technical/Manning)', color:'#2a6ab5', x:110 },
|
||||||
|
{ label:'System\n(Auto-email)', color:'#555', x:310 },
|
||||||
|
{ label:'Manager', color:'#c8871a', x:510 },
|
||||||
|
{ label:'Accounts', color:'#1a4abf', x:710 },
|
||||||
|
];
|
||||||
|
const LW = 180;
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
{
|
||||||
|
y:130, label:'Creates PO',
|
||||||
|
actor:0,
|
||||||
|
notifs:[
|
||||||
|
{ from:0, to:1, label:'confirmation' },
|
||||||
|
{ from:1, to:0, label:'PO-#### created' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y:195, label:'Submits PO',
|
||||||
|
actor:0,
|
||||||
|
notifs:[
|
||||||
|
{ from:0, to:1, label:'submit trigger' },
|
||||||
|
{ from:1, to:2, label:'New PO for review' },
|
||||||
|
{ from:1, to:0, label:'Submitted ✓' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y:270, label:'No Vendor ID —\nUser asked to provide',
|
||||||
|
actor:2,
|
||||||
|
notifs:[
|
||||||
|
{ from:2, to:1, label:'vendor ID request' },
|
||||||
|
{ from:1, to:0, label:'Action needed:\nprovide Vendor ID' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y:340, label:'User provides\nVendor ID',
|
||||||
|
actor:0,
|
||||||
|
notifs:[
|
||||||
|
{ from:0, to:1, label:'vendor ID update' },
|
||||||
|
{ from:1, to:2, label:'Vendor ID added' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y:405, label:'Asks for Edits',
|
||||||
|
actor:2,
|
||||||
|
notifs:[
|
||||||
|
{ from:2, to:1, label:'edits trigger + comment' },
|
||||||
|
{ from:1, to:0, label:'Edits requested\n+ comment' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y:468, label:'Approves PO\n(or Approve+Note)',
|
||||||
|
actor:2,
|
||||||
|
notifs:[
|
||||||
|
{ from:2, to:1, label:'approval trigger' },
|
||||||
|
{ from:1, to:0, label:'PO Approved ✓' },
|
||||||
|
{ from:1, to:3, label:'PO attached\nfor payment' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y:545, label:'Rejects PO',
|
||||||
|
actor:2,
|
||||||
|
notifs:[
|
||||||
|
{ from:2, to:1, label:'reject + reason' },
|
||||||
|
{ from:1, to:0, label:'PO Rejected\n+ reason' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
y:605, label:'Processes Payment\n→ User confirms receipt',
|
||||||
|
actor:3,
|
||||||
|
notifs:[
|
||||||
|
{ from:3, to:1, label:'payment trigger' },
|
||||||
|
{ from:1, to:0, label:'Payment sent ✓\nPlease confirm' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const laneX = (i) => LANES[i].x;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ background:'#fafaf8', borderRadius:4 }}>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<text x={W/2} y={22} textAnchor="middle" fontFamily="Caveat"
|
||||||
|
fontSize={16} fontWeight="700" fill="#222">Email Notification Triggers</text>
|
||||||
|
|
||||||
|
{/* Lane headers */}
|
||||||
|
{LANES.map((l,i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={l.x-LW/2} y={35} width={LW} height={50} rx={3}
|
||||||
|
fill={l.color} stroke="none"/>
|
||||||
|
{l.label.split('\n').map((line,j) => (
|
||||||
|
<text key={j} x={l.x} y={55+j*16} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={12} fontWeight="700" fill="#fff">{line}</text>
|
||||||
|
))}
|
||||||
|
{/* Vertical lane line */}
|
||||||
|
<line x1={l.x} y1={85} x2={l.x} y2={H-20}
|
||||||
|
stroke={l.color} strokeWidth={1} strokeOpacity={0.2} strokeDasharray="4,4"/>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Lane dividers */}
|
||||||
|
{[210, 410, 610].map(x => (
|
||||||
|
<line key={x} x1={x} y1={35} x2={x} y2={H-20}
|
||||||
|
stroke="#ddd" strokeWidth={1}/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Events */}
|
||||||
|
{events.map((ev, ei) => {
|
||||||
|
const actorColor = LANES[ev.actor].color;
|
||||||
|
return (
|
||||||
|
<g key={ei}>
|
||||||
|
{/* Event marker on actor lane */}
|
||||||
|
<circle cx={laneX(ev.actor)} cy={ev.y} r={7}
|
||||||
|
fill={actorColor} stroke="white" strokeWidth={1.5}/>
|
||||||
|
{/* Event label */}
|
||||||
|
{ev.label.split('\n').map((line,j) => (
|
||||||
|
<text key={j} x={laneX(ev.actor)} y={ev.y + 18 + j*14}
|
||||||
|
textAnchor="middle" fontFamily="Caveat" fontSize={11}
|
||||||
|
fontWeight="700" fill={actorColor}>{line}</text>
|
||||||
|
))}
|
||||||
|
{/* Notification arrows */}
|
||||||
|
{ev.notifs.map((n,ni) => {
|
||||||
|
const x1 = laneX(n.from), x2 = laneX(n.to);
|
||||||
|
const yOff = ei*2 + ni*14;
|
||||||
|
const ny = ev.y + yOff;
|
||||||
|
const color = LANES[n.to === 1 ? n.from : n.to].color;
|
||||||
|
const mid = (x1+x2)/2;
|
||||||
|
const isSystem = n.from === 1 || n.to === 1;
|
||||||
|
return (
|
||||||
|
<g key={ni}>
|
||||||
|
<defs>
|
||||||
|
<marker id={`an${ei}${ni}`} markerWidth="6" markerHeight="6"
|
||||||
|
refX="5" refY="3" orient="auto">
|
||||||
|
<path d="M0,0 L0,6 L6,3 z" fill={color}/>
|
||||||
|
</marker>
|
||||||
|
</defs>
|
||||||
|
<line x1={x1+(x1<x2?7:-7)} y1={ny} x2={x2+(x1<x2?-7:7)} y2={ny}
|
||||||
|
stroke={color} strokeWidth={1.3} strokeDasharray={isSystem ? '4,3' : undefined}
|
||||||
|
markerEnd={`url(#an${ei}${ni})`}/>
|
||||||
|
{n.label && (
|
||||||
|
<text x={mid} y={ny-3} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={9.5} fill={color} fontStyle="italic">
|
||||||
|
{n.label.split('\n')[0]}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Row separators */}
|
||||||
|
{events.slice(0,-1).map((ev,i) => (
|
||||||
|
<line key={i} x1={20} y1={(ev.y + events[i+1].y)/2}
|
||||||
|
x2={W-20} y2={(ev.y + events[i+1].y)/2}
|
||||||
|
stroke="#eee" strokeWidth={1}/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<g transform="translate(20,640)">
|
||||||
|
<line x1={0} y1={6} x2={28} y2={6} stroke="#555" strokeWidth={1.3} strokeDasharray="4,3"/>
|
||||||
|
<text x={34} y={10} fontFamily="Caveat" fontSize={11} fill="#888">System-triggered email</text>
|
||||||
|
<line x1={160} y1={6} x2={188} y2={6} stroke="#2a6ab5" strokeWidth={1.3}/>
|
||||||
|
<text x={194} y={10} fontFamily="Caveat" fontSize={11} fill="#888">Direct action</text>
|
||||||
|
<circle cx={280} cy={6} r={5} fill="#c8871a"/>
|
||||||
|
<text x={290} y={10} fontFamily="Caveat" fontSize={11} fill="#888">Actor initiating event</text>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════
|
||||||
|
DIAGRAM 3 — ROLES & PERMISSIONS MATRIX
|
||||||
|
══════════════════════════════════════════════════════ */
|
||||||
|
function PermissionsMatrix() {
|
||||||
|
const roles = [
|
||||||
|
{ id:'tech', label:'Technical', color:'#2a6ab5' },
|
||||||
|
{ id:'mann', label:'Manning', color:'#2a6ab5' },
|
||||||
|
{ id:'accts', label:'Accounts', color:'#1a4abf' },
|
||||||
|
{ id:'mgr', label:'Manager', color:'#c8871a' },
|
||||||
|
{ id:'super', label:'SuperUser', color:'#7a2abf' },
|
||||||
|
{ id:'audit', label:'Auditor', color:'#555' },
|
||||||
|
{ id:'admin', label:'Admin', color:'#1a1a2e' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ✓ = always | ○ = opt-in | — = never
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
title:'PO — Creation',
|
||||||
|
rows:[
|
||||||
|
{ label:'Create PO', perms:['✓','✓','—','—','✓','—','✓'] },
|
||||||
|
{ label:'Add Line Items', perms:['✓','✓','—','—','✓','—','✓'] },
|
||||||
|
{ label:'Attach Documents', perms:['✓','✓','—','—','✓','—','✓'] },
|
||||||
|
{ label:'Submit PO', perms:['✓','✓','—','—','✓','—','✓'] },
|
||||||
|
{ label:'Edit Own PO (draft)',perms:['✓','✓','—','—','✓','—','✓'] },
|
||||||
|
{ label:'Provide Vendor ID', perms:['✓','✓','—','—','✓','—','✓'] },
|
||||||
|
{ label:'Confirm Receipt', perms:['✓','✓','—','—','✓','—','✓'] },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'PO — Tracking',
|
||||||
|
rows:[
|
||||||
|
{ label:'View Own POs', perms:['✓','✓','✓','✓','✓','✓','✓'] },
|
||||||
|
{ label:'View All POs', perms:['○','○','✓','✓','✓','✓','✓'] },
|
||||||
|
{ label:'View Vessel Report', perms:['○','○','○','✓','✓','✓','✓'] },
|
||||||
|
{ label:'View Project Report',perms:['○','○','○','✓','✓','✓','✓'] },
|
||||||
|
{ label:'Export Reports', perms:['—','—','✓','✓','✓','✓','✓'] },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'PO — Approval (Manager)',
|
||||||
|
rows:[
|
||||||
|
{ label:'Approve PO', perms:['—','—','—','✓','✓','—','✓'] },
|
||||||
|
{ label:'Reject PO', perms:['—','—','—','✓','✓','—','✓'] },
|
||||||
|
{ label:'Ask for Edits', perms:['—','—','—','✓','✓','—','✓'] },
|
||||||
|
{ label:'Approve with Note', perms:['—','—','—','✓','✓','—','✓'] },
|
||||||
|
{ label:'Review Vendor ID', perms:['—','—','—','✓','✓','—','✓'] },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'PO — Payment (Accounts)',
|
||||||
|
rows:[
|
||||||
|
{ label:'View Payment Queue', perms:['—','—','✓','○','✓','—','✓'] },
|
||||||
|
{ label:'Process Payment', perms:['—','—','✓','—','✓','—','✓'] },
|
||||||
|
{ label:'Mark Paid + Upload Receipt',perms:['—','—','✓','—','✓','—','✓'] },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title:'Administration',
|
||||||
|
rows:[
|
||||||
|
{ label:'Manage Users', perms:['—','—','—','—','✓','—','✓'] },
|
||||||
|
{ label:'Set Opt-in Perms', perms:['—','—','—','—','✓','—','✓'] },
|
||||||
|
{ label:'View Audit Log', perms:['—','—','—','○','✓','✓','✓'] },
|
||||||
|
{ label:'System Config', perms:['—','—','—','—','—','—','✓'] },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const COL_W = 68, ROW_H = 22, LABEL_W = 178;
|
||||||
|
const totalRows = sections.reduce((a,s) => a + s.rows.length + 1, 0) + 1;
|
||||||
|
const W = LABEL_W + roles.length * COL_W + 20;
|
||||||
|
const H = 60 + totalRows * ROW_H + 40;
|
||||||
|
|
||||||
|
const permColor = (p) => p === '✓' ? '#1a7a48' : p === '○' ? '#c8871a' : '#ccc';
|
||||||
|
const permBg = (p) => p === '✓' ? '#d8f0e0' : p === '○' ? '#fdf3d0' : 'transparent';
|
||||||
|
|
||||||
|
let rowIdx = 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ background:'#fafaf8', borderRadius:4 }}>
|
||||||
|
|
||||||
|
<text x={W/2} y={22} textAnchor="middle" fontFamily="Caveat"
|
||||||
|
fontSize={16} fontWeight="700" fill="#222">Roles & Permissions Matrix</text>
|
||||||
|
|
||||||
|
{/* Role headers */}
|
||||||
|
{roles.map((r,i) => (
|
||||||
|
<g key={i}>
|
||||||
|
<rect x={LABEL_W + i*COL_W + 2} y={34} width={COL_W-4} height={30} rx={3}
|
||||||
|
fill={r.color}/>
|
||||||
|
<text x={LABEL_W + i*COL_W + COL_W/2} y={50} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={11} fontWeight="700" fill="#fff"
|
||||||
|
transform={`rotate(-20,${LABEL_W+i*COL_W+COL_W/2},50)`}>{r.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Sections and rows */}
|
||||||
|
{(() => {
|
||||||
|
const elems = [];
|
||||||
|
let y = 68;
|
||||||
|
sections.forEach((sec, si) => {
|
||||||
|
// Section header
|
||||||
|
elems.push(
|
||||||
|
<g key={`sec${si}`}>
|
||||||
|
<rect x={0} y={y} width={W} height={ROW_H} fill="#e8e4dc"/>
|
||||||
|
<text x={8} y={y+15} fontFamily="Caveat" fontSize={12}
|
||||||
|
fontWeight="700" fill="#444">{sec.title}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
y += ROW_H;
|
||||||
|
|
||||||
|
sec.rows.forEach((row, ri) => {
|
||||||
|
const bg = (si+ri) % 2 === 0 ? '#fafaf8' : '#f5f2ee';
|
||||||
|
elems.push(
|
||||||
|
<g key={`r${si}${ri}`}>
|
||||||
|
<rect x={0} y={y} width={W} height={ROW_H} fill={bg}/>
|
||||||
|
<text x={10} y={y+15} fontFamily="Caveat" fontSize={12} fill="#333">{row.label}</text>
|
||||||
|
<line x1={0} y1={y+ROW_H} x2={W} y2={y+ROW_H} stroke="#e8e4dc" strokeWidth={1}/>
|
||||||
|
{row.perms.map((p,pi) => {
|
||||||
|
const cx = LABEL_W + pi*COL_W + COL_W/2;
|
||||||
|
const cy = y + ROW_H/2;
|
||||||
|
return (
|
||||||
|
<g key={pi}>
|
||||||
|
{p !== '—' && <rect x={cx-14} y={cy-9} width={28} height={18} rx={9}
|
||||||
|
fill={permBg(p)} stroke={permColor(p)} strokeWidth={1.2}/>}
|
||||||
|
<text x={cx} y={cy+5} textAnchor="middle"
|
||||||
|
fontFamily="Caveat" fontSize={13} fontWeight="700"
|
||||||
|
fill={permColor(p)}>{p}</text>
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
y += ROW_H;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Legend
|
||||||
|
elems.push(
|
||||||
|
<g key="legend" transform={`translate(8,${y+12})`}>
|
||||||
|
{[
|
||||||
|
{ sym:'✓', color:'#1a7a48', bg:'#d8f0e0', label:'Always allowed' },
|
||||||
|
{ sym:'○', color:'#c8871a', bg:'#fdf3d0', label:'Opt-in (can be granted)' },
|
||||||
|
{ sym:'—', color:'#ccc', bg:'transparent', label:'Not permitted' },
|
||||||
|
].map((l,i) => (
|
||||||
|
<g key={i} transform={`translate(${i*200},0)`}>
|
||||||
|
<rect x={0} y={-10} width={20} height={18} rx={9}
|
||||||
|
fill={l.bg} stroke={l.color} strokeWidth={1.2}/>
|
||||||
|
<text x={10} y={5} textAnchor="middle" fontFamily="Caveat"
|
||||||
|
fontSize={13} fontWeight="700" fill={l.color}>{l.sym}</text>
|
||||||
|
<text x={28} y={5} fontFamily="Caveat" fontSize={12} fill="#666">{l.label}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
return elems;
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Column dividers */}
|
||||||
|
{roles.map((_,i) => (
|
||||||
|
<line key={i} x1={LABEL_W+i*COL_W} y1={34} x2={LABEL_W+i*COL_W} y2={H-20}
|
||||||
|
stroke="#ddd" strokeWidth={1}/>
|
||||||
|
))}
|
||||||
|
<line x1={LABEL_W} y1={34} x2={LABEL_W} y2={H-20} stroke="#bbb" strokeWidth={1.5}/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════
|
||||||
|
DIAGRAM 4 — GAP ANALYSIS CARD
|
||||||
|
══════════════════════════════════════════════════════ */
|
||||||
|
function GapAnalysis() {
|
||||||
|
const W = 860, H = 480;
|
||||||
|
|
||||||
|
const gaps = [
|
||||||
|
{ cat:'Roles', color:'#7a2abf', items:[
|
||||||
|
'Add SuperUser role (all permissions + user management)',
|
||||||
|
'Add Auditor role (read-only + audit log access)',
|
||||||
|
'Distinguish "User" vs "Admin" vs "SuperUser" in account types',
|
||||||
|
]},
|
||||||
|
{ cat:'PO States', color:'#c8871a', items:[
|
||||||
|
'Add VENDOR_ID_PENDING state before MGR_REVIEW completes',
|
||||||
|
'Add PAID_DELIVERED state (separate from SENT_FOR_PAYMENT)',
|
||||||
|
'Add CLOSED state — requires user receipt confirmation',
|
||||||
|
'Rejected POs archived to separate rejected_orders store',
|
||||||
|
]},
|
||||||
|
{ cat:'Entities', color:'#2a6ab5', items:[
|
||||||
|
'Add VENDOR entity (vendor_id, name, contact) linked to PO',
|
||||||
|
'Add RECEIPT entity (po_id, file_url, confirmed_by, confirmed_at)',
|
||||||
|
'Update PO status enum with all new states',
|
||||||
|
]},
|
||||||
|
{ cat:'Notifications', color:'#1a7a48', items:[
|
||||||
|
'Email on every state transition (not just approval)',
|
||||||
|
'Mail to Accounts must include PO document attached',
|
||||||
|
'Email on vendor ID request and provision',
|
||||||
|
'Closure email to all stakeholders',
|
||||||
|
]},
|
||||||
|
{ cat:'Features', color:'#1a4abf', items:[
|
||||||
|
'PO Insights: vessel-wise spend view',
|
||||||
|
'PO Insights: project-wise spend view',
|
||||||
|
'Opt-in permissions model (admin-configurable per user)',
|
||||||
|
]},
|
||||||
|
];
|
||||||
|
|
||||||
|
let y = 45;
|
||||||
|
return (
|
||||||
|
<svg width={W} height={H} viewBox={`0 0 ${W} ${H}`} style={{ background:'#fffef8', borderRadius:4, border:'2px dashed #c8971a' }}>
|
||||||
|
<text x={W/2} y={22} textAnchor="middle" fontFamily="Caveat" fontSize={16}
|
||||||
|
fontWeight="700" fill="#c8971a">⚠ Gap Analysis — Changes vs. Original Design</text>
|
||||||
|
|
||||||
|
{(() => {
|
||||||
|
const elems = [];
|
||||||
|
let cy = 40;
|
||||||
|
const colW = W/2 - 20;
|
||||||
|
gaps.forEach((g, gi) => {
|
||||||
|
const col = gi < 3 ? 0 : 1;
|
||||||
|
const x = col * (colW + 20) + 14;
|
||||||
|
elems.push(
|
||||||
|
<g key={gi}>
|
||||||
|
<rect x={x} y={cy-2} width={colW} height={16+g.items.length*20} rx={2}
|
||||||
|
fill={g.color} fillOpacity={0.06} stroke={g.color} strokeWidth={1.5}/>
|
||||||
|
<rect x={x} y={cy-2} width={110} height={18} rx={2}
|
||||||
|
fill={g.color}/>
|
||||||
|
<text x={x+8} y={cy+11} fontFamily="Caveat" fontSize={12}
|
||||||
|
fontWeight="700" fill="#fff">{g.cat}</text>
|
||||||
|
{g.items.map((item,ii) => (
|
||||||
|
<g key={ii}>
|
||||||
|
<circle cx={x+14} cy={cy+26+ii*20} r={3} fill={g.color}/>
|
||||||
|
<text x={x+22} y={cy+30+ii*20} fontFamily="Caveat"
|
||||||
|
fontSize={11.5} fill="#333">{item}</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
if (gi === 2) cy = 40; // reset for second column
|
||||||
|
else cy += 28 + g.items.length*20;
|
||||||
|
});
|
||||||
|
return elems;
|
||||||
|
})()}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ══════════════════════════════════════════════════════
|
||||||
|
APP
|
||||||
|
══════════════════════════════════════════════════════ */
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<DesignCanvas title="Pelagia Marine — System Diagrams v2">
|
||||||
|
|
||||||
|
<DCSection id="s1" title="PO State Machine">
|
||||||
|
<DCArtboard id="sm" label="Purchase Order State Machine" width={960} height={800}>
|
||||||
|
<StateMachineDiagram/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
|
||||||
|
<DCSection id="s2" title="Notifications & Permissions">
|
||||||
|
<DCArtboard id="notif" label="Email Notification Triggers" width={900} height={660}>
|
||||||
|
<NotificationFlowDiagram/>
|
||||||
|
</DCArtboard>
|
||||||
|
<DCArtboard id="perms" label="Roles & Permissions Matrix" width={660} height={620}>
|
||||||
|
<PermissionsMatrix/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
|
||||||
|
<DCSection id="s3" title="Gap Analysis — Notes vs. Current Diagrams">
|
||||||
|
<DCArtboard id="gaps" label="What needs updating" width={860} height={480}>
|
||||||
|
<GapAnalysis/>
|
||||||
|
</DCArtboard>
|
||||||
|
</DCSection>
|
||||||
|
|
||||||
|
</DesignCanvas>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
Prototype/Sample_PO.xlsx
Normal file
BIN
Prototype/Sample_PO.xlsx
Normal file
Binary file not shown.
622
Prototype/design-canvas.jsx
Normal file
622
Prototype/design-canvas.jsx
Normal file
|
|
@ -0,0 +1,622 @@
|
||||||
|
|
||||||
|
// DesignCanvas.jsx — Figma-ish design canvas wrapper
|
||||||
|
// Warm gray grid bg + Sections + Artboards + PostIt notes.
|
||||||
|
// Artboards are reorderable (grip-drag), labels/titles are inline-editable,
|
||||||
|
// and any artboard can be opened in a fullscreen focus overlay (←/→/Esc).
|
||||||
|
// State persists to a .design-canvas.state.json sidecar via the host
|
||||||
|
// bridge. No assets, no deps.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <DesignCanvas>
|
||||||
|
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
|
||||||
|
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
|
||||||
|
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
|
||||||
|
// </DCSection>
|
||||||
|
// </DesignCanvas>
|
||||||
|
|
||||||
|
const DC = {
|
||||||
|
bg: '#f0eee9',
|
||||||
|
grid: 'rgba(0,0,0,0.06)',
|
||||||
|
label: 'rgba(60,50,40,0.7)',
|
||||||
|
title: 'rgba(40,30,20,0.85)',
|
||||||
|
subtitle: 'rgba(60,50,40,0.6)',
|
||||||
|
postitBg: '#fef4a8',
|
||||||
|
postitText: '#5a4a2a',
|
||||||
|
font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||||
|
};
|
||||||
|
|
||||||
|
// One-time CSS injection (classes are dc-prefixed so they don't collide with
|
||||||
|
// the hosted design's own styles).
|
||||||
|
if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) {
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.id = 'dc-styles';
|
||||||
|
s.textContent = [
|
||||||
|
'.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}',
|
||||||
|
'.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}',
|
||||||
|
'[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}',
|
||||||
|
'[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}',
|
||||||
|
'[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}',
|
||||||
|
'.dc-card{transition:box-shadow .15s,transform .15s}',
|
||||||
|
'.dc-card *{scrollbar-width:none}',
|
||||||
|
'.dc-card *::-webkit-scrollbar{display:none}',
|
||||||
|
'.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px}',
|
||||||
|
'.dc-grip{cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s}',
|
||||||
|
'.dc-grip:hover{background:rgba(0,0,0,.08)}',
|
||||||
|
'.dc-grip:active{cursor:grabbing}',
|
||||||
|
'.dc-labeltext{cursor:pointer;border-radius:4px;padding:3px 6px;display:flex;align-items:center;transition:background .12s}',
|
||||||
|
'.dc-labeltext:hover{background:rgba(0,0,0,.05)}',
|
||||||
|
'.dc-expand{position:absolute;bottom:100%;right:0;margin-bottom:5px;z-index:2;opacity:0;transition:opacity .12s,background .12s;',
|
||||||
|
' width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;',
|
||||||
|
' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center}',
|
||||||
|
'.dc-expand:hover{background:rgba(0,0,0,.06);color:#2a251f}',
|
||||||
|
'[data-dc-slot]:hover .dc-expand{opacity:1}',
|
||||||
|
].join('\n');
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DCCtx = React.createContext(null);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DesignCanvas — stateful wrapper around the pan/zoom viewport.
|
||||||
|
// Owns runtime state (per-section order, renamed titles/labels, focused
|
||||||
|
// artboard). Order/titles/labels persist to a .design-canvas.state.json
|
||||||
|
// sidecar next to the HTML. Reads go via plain fetch() so the saved
|
||||||
|
// arrangement is visible anywhere the HTML + sidecar are served together
|
||||||
|
// (omelette preview, direct link, downloaded zip). Writes go through the
|
||||||
|
// host's window.omelette bridge — editing requires the omelette runtime.
|
||||||
|
// Focus is ephemeral.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
const DC_STATE_FILE = '.design-canvas.state.json';
|
||||||
|
|
||||||
|
function DesignCanvas({ children, minScale, maxScale, style }) {
|
||||||
|
const [state, setState] = React.useState({ sections: {}, focus: null });
|
||||||
|
// Hold rendering until the sidecar read settles so the saved order/titles
|
||||||
|
// appear on first paint (no source-order flash). didRead gates writes until
|
||||||
|
// the read settles so the empty initial state can't clobber a slow read;
|
||||||
|
// skipNextWrite suppresses the one echo-write that would otherwise follow
|
||||||
|
// hydration.
|
||||||
|
const [ready, setReady] = React.useState(false);
|
||||||
|
const didRead = React.useRef(false);
|
||||||
|
const skipNextWrite = React.useRef(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let off = false;
|
||||||
|
fetch('./' + DC_STATE_FILE)
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((saved) => {
|
||||||
|
if (off || !saved || !saved.sections) return;
|
||||||
|
skipNextWrite.current = true;
|
||||||
|
setState((s) => ({ ...s, sections: saved.sections }));
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => { didRead.current = true; if (!off) setReady(true); });
|
||||||
|
const t = setTimeout(() => { if (!off) setReady(true); }, 150);
|
||||||
|
return () => { off = true; clearTimeout(t); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!didRead.current) return;
|
||||||
|
if (skipNextWrite.current) { skipNextWrite.current = false; return; }
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {});
|
||||||
|
}, 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [state.sections]);
|
||||||
|
|
||||||
|
// Build registries synchronously from children so FocusOverlay can read
|
||||||
|
// them in the same render. Only direct DCSection > DCArtboard children are
|
||||||
|
// walked — wrapping them in other elements opts out of focus/reorder.
|
||||||
|
const registry = {}; // slotId -> { sectionId, artboard }
|
||||||
|
const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] }
|
||||||
|
const sectionOrder = [];
|
||||||
|
React.Children.forEach(children, (sec) => {
|
||||||
|
if (!sec || sec.type !== DCSection) return;
|
||||||
|
const sid = sec.props.id ?? sec.props.title;
|
||||||
|
if (!sid) return;
|
||||||
|
sectionOrder.push(sid);
|
||||||
|
const persisted = state.sections[sid] || {};
|
||||||
|
const srcIds = [];
|
||||||
|
React.Children.forEach(sec.props.children, (ab) => {
|
||||||
|
if (!ab || ab.type !== DCArtboard) return;
|
||||||
|
const aid = ab.props.id ?? ab.props.label;
|
||||||
|
if (!aid) return;
|
||||||
|
registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab };
|
||||||
|
srcIds.push(aid);
|
||||||
|
});
|
||||||
|
const kept = (persisted.order || []).filter((k) => srcIds.includes(k));
|
||||||
|
sectionMeta[sid] = {
|
||||||
|
title: persisted.title ?? sec.props.title,
|
||||||
|
subtitle: sec.props.subtitle,
|
||||||
|
slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const api = React.useMemo(() => ({
|
||||||
|
state,
|
||||||
|
section: (id) => state.sections[id] || {},
|
||||||
|
patchSection: (id, p) => setState((s) => ({
|
||||||
|
...s,
|
||||||
|
sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } },
|
||||||
|
})),
|
||||||
|
setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })),
|
||||||
|
}), [state]);
|
||||||
|
|
||||||
|
// Esc exits focus; any outside pointerdown commits an in-progress rename.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); };
|
||||||
|
const onPd = (e) => {
|
||||||
|
const ae = document.activeElement;
|
||||||
|
if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
document.addEventListener('pointerdown', onPd, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
document.removeEventListener('pointerdown', onPd, true);
|
||||||
|
};
|
||||||
|
}, [api]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DCCtx.Provider value={api}>
|
||||||
|
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
|
||||||
|
{state.focus && registry[state.focus] && (
|
||||||
|
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
|
||||||
|
)}
|
||||||
|
</DCCtx.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DCViewport — transform-based pan/zoom (internal)
|
||||||
|
//
|
||||||
|
// Input mapping (Figma-style):
|
||||||
|
// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events)
|
||||||
|
// • trackpad scroll → pan (two-finger)
|
||||||
|
// • mouse wheel → zoom (notched; distinguished from trackpad scroll)
|
||||||
|
// • middle-drag / primary-drag-on-bg → pan
|
||||||
|
//
|
||||||
|
// Transform state lives in a ref and is written straight to the DOM
|
||||||
|
// (translate3d + will-change) so wheel ticks don't go through React —
|
||||||
|
// keeps pans at 60fps on dense canvases.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) {
|
||||||
|
const vpRef = React.useRef(null);
|
||||||
|
const worldRef = React.useRef(null);
|
||||||
|
const tf = React.useRef({ x: 0, y: 0, scale: 1 });
|
||||||
|
|
||||||
|
const apply = React.useCallback(() => {
|
||||||
|
const { x, y, scale } = tf.current;
|
||||||
|
const el = worldRef.current;
|
||||||
|
if (el) el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const vp = vpRef.current;
|
||||||
|
if (!vp) return;
|
||||||
|
|
||||||
|
const zoomAt = (cx, cy, factor) => {
|
||||||
|
const r = vp.getBoundingClientRect();
|
||||||
|
const px = cx - r.left, py = cy - r.top;
|
||||||
|
const t = tf.current;
|
||||||
|
const next = Math.min(maxScale, Math.max(minScale, t.scale * factor));
|
||||||
|
const k = next / t.scale;
|
||||||
|
// keep the world point under the cursor fixed
|
||||||
|
t.x = px - (px - t.x) * k;
|
||||||
|
t.y = py - (py - t.y) * k;
|
||||||
|
t.scale = next;
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends
|
||||||
|
// line-mode deltas (Firefox) or large integer pixel deltas with no X
|
||||||
|
// component (Chrome/Safari, typically multiples of 100/120). Trackpad
|
||||||
|
// two-finger scroll sends small/fractional pixel deltas, often with
|
||||||
|
// non-zero deltaX. ctrlKey is set by the browser for trackpad pinch.
|
||||||
|
const isMouseWheel = (e) =>
|
||||||
|
e.deltaMode !== 0 ||
|
||||||
|
(e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);
|
||||||
|
|
||||||
|
const onWheel = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels
|
||||||
|
if (e.ctrlKey) {
|
||||||
|
// trackpad pinch (or explicit ctrl+wheel)
|
||||||
|
zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));
|
||||||
|
} else if (isMouseWheel(e)) {
|
||||||
|
// notched mouse wheel — fixed-ratio step per click
|
||||||
|
zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));
|
||||||
|
} else {
|
||||||
|
// trackpad two-finger scroll — pan
|
||||||
|
tf.current.x -= e.deltaX;
|
||||||
|
tf.current.y -= e.deltaY;
|
||||||
|
apply();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safari sends native gesture* events for trackpad pinch with a smooth
|
||||||
|
// e.scale; preferring these over the ctrl+wheel fallback gives a much
|
||||||
|
// better feel there. No-ops on other browsers. Safari also fires
|
||||||
|
// ctrlKey wheel events during the same pinch — isGesturing makes
|
||||||
|
// onWheel drop those entirely so they neither zoom nor pan.
|
||||||
|
let gsBase = 1;
|
||||||
|
let isGesturing = false;
|
||||||
|
const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; };
|
||||||
|
const onGestureChange = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale);
|
||||||
|
};
|
||||||
|
const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; };
|
||||||
|
|
||||||
|
// Drag-pan: middle button anywhere, or primary button on canvas
|
||||||
|
// background (anything that isn't an artboard or an inline editor).
|
||||||
|
let drag = null;
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
const onBg = !e.target.closest('[data-dc-slot], .dc-editable');
|
||||||
|
if (!(e.button === 1 || (e.button === 0 && onBg))) return;
|
||||||
|
e.preventDefault();
|
||||||
|
vp.setPointerCapture(e.pointerId);
|
||||||
|
drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };
|
||||||
|
vp.style.cursor = 'grabbing';
|
||||||
|
};
|
||||||
|
const onPointerMove = (e) => {
|
||||||
|
if (!drag || e.pointerId !== drag.id) return;
|
||||||
|
tf.current.x += e.clientX - drag.lx;
|
||||||
|
tf.current.y += e.clientY - drag.ly;
|
||||||
|
drag.lx = e.clientX; drag.ly = e.clientY;
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
const onPointerUp = (e) => {
|
||||||
|
if (!drag || e.pointerId !== drag.id) return;
|
||||||
|
vp.releasePointerCapture(e.pointerId);
|
||||||
|
drag = null;
|
||||||
|
vp.style.cursor = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
vp.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
vp.addEventListener('gesturestart', onGestureStart, { passive: false });
|
||||||
|
vp.addEventListener('gesturechange', onGestureChange, { passive: false });
|
||||||
|
vp.addEventListener('gestureend', onGestureEnd, { passive: false });
|
||||||
|
vp.addEventListener('pointerdown', onPointerDown);
|
||||||
|
vp.addEventListener('pointermove', onPointerMove);
|
||||||
|
vp.addEventListener('pointerup', onPointerUp);
|
||||||
|
vp.addEventListener('pointercancel', onPointerUp);
|
||||||
|
return () => {
|
||||||
|
vp.removeEventListener('wheel', onWheel);
|
||||||
|
vp.removeEventListener('gesturestart', onGestureStart);
|
||||||
|
vp.removeEventListener('gesturechange', onGestureChange);
|
||||||
|
vp.removeEventListener('gestureend', onGestureEnd);
|
||||||
|
vp.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
vp.removeEventListener('pointermove', onPointerMove);
|
||||||
|
vp.removeEventListener('pointerup', onPointerUp);
|
||||||
|
vp.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
};
|
||||||
|
}, [apply, minScale, maxScale]);
|
||||||
|
|
||||||
|
const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={vpRef}
|
||||||
|
className="design-canvas"
|
||||||
|
style={{
|
||||||
|
height: '100vh', width: '100vw',
|
||||||
|
background: DC.bg,
|
||||||
|
overflow: 'hidden',
|
||||||
|
overscrollBehavior: 'none',
|
||||||
|
touchAction: 'none',
|
||||||
|
position: 'relative',
|
||||||
|
fontFamily: DC.font,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
...style,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={worldRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute', top: 0, left: 0,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
willChange: 'transform',
|
||||||
|
width: 'max-content', minWidth: '100%',
|
||||||
|
minHeight: '100%',
|
||||||
|
padding: '60px 0 80px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// DCSection — editable title + h-row of artboards in persisted order
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCSection({ id, title, subtitle, children, gap = 48 }) {
|
||||||
|
const ctx = React.useContext(DCCtx);
|
||||||
|
const sid = id ?? title;
|
||||||
|
const all = React.Children.toArray(children);
|
||||||
|
const artboards = all.filter((c) => c && c.type === DCArtboard);
|
||||||
|
const rest = all.filter((c) => !(c && c.type === DCArtboard));
|
||||||
|
const srcOrder = artboards.map((a) => a.props.id ?? a.props.label);
|
||||||
|
const sec = (ctx && sid && ctx.section(sid)) || {};
|
||||||
|
|
||||||
|
const order = React.useMemo(() => {
|
||||||
|
const kept = (sec.order || []).filter((k) => srcOrder.includes(k));
|
||||||
|
return [...kept, ...srcOrder.filter((k) => !kept.includes(k))];
|
||||||
|
}, [sec.order, srcOrder.join('|')]);
|
||||||
|
|
||||||
|
const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-dc-section={sid} style={{ marginBottom: 80, position: 'relative' }}>
|
||||||
|
<div style={{ padding: '0 60px 56px' }}>
|
||||||
|
<DCEditable tag="div" value={sec.title ?? title}
|
||||||
|
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
|
||||||
|
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
|
||||||
|
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
|
||||||
|
{order.map((k) => (
|
||||||
|
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
|
||||||
|
label={(sec.labels || {})[k] ?? byId[k].props.label}
|
||||||
|
onRename={(v) => ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))}
|
||||||
|
onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })}
|
||||||
|
onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{rest}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// DCArtboard — marker; rendered by DCArtboardFrame via DCSection.
|
||||||
|
function DCArtboard() { return null; }
|
||||||
|
|
||||||
|
function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus }) {
|
||||||
|
const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props;
|
||||||
|
const id = rawId ?? rawLabel;
|
||||||
|
const ref = React.useRef(null);
|
||||||
|
|
||||||
|
// Live drag-reorder: dragged card sticks to cursor; siblings slide into
|
||||||
|
// their would-be slots in real time via transforms. DOM order only
|
||||||
|
// changes on drop.
|
||||||
|
const onGripDown = (e) => {
|
||||||
|
e.preventDefault(); e.stopPropagation();
|
||||||
|
const me = ref.current;
|
||||||
|
// translateX is applied in local (pre-scale) space but pointer deltas and
|
||||||
|
// getBoundingClientRect().left are screen-space — divide by the viewport's
|
||||||
|
// current scale so the dragged card tracks the cursor at any zoom level.
|
||||||
|
const scale = me.getBoundingClientRect().width / me.offsetWidth || 1;
|
||||||
|
const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`));
|
||||||
|
const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left }));
|
||||||
|
const slotXs = homes.map((h) => h.x);
|
||||||
|
const startIdx = order.indexOf(id);
|
||||||
|
const startX = e.clientX;
|
||||||
|
let liveOrder = order.slice();
|
||||||
|
me.classList.add('dc-dragging');
|
||||||
|
|
||||||
|
const layout = () => {
|
||||||
|
for (const h of homes) {
|
||||||
|
if (h.id === id) continue;
|
||||||
|
const slot = liveOrder.indexOf(h.id);
|
||||||
|
h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startX;
|
||||||
|
me.style.transform = `translateX(${dx / scale}px)`;
|
||||||
|
const cur = homes[startIdx].x + dx;
|
||||||
|
let nearest = 0, best = Infinity;
|
||||||
|
for (let i = 0; i < slotXs.length; i++) {
|
||||||
|
const d = Math.abs(slotXs[i] - cur);
|
||||||
|
if (d < best) { best = d; nearest = i; }
|
||||||
|
}
|
||||||
|
if (liveOrder.indexOf(id) !== nearest) {
|
||||||
|
liveOrder = order.filter((k) => k !== id);
|
||||||
|
liveOrder.splice(nearest, 0, id);
|
||||||
|
layout();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const up = () => {
|
||||||
|
document.removeEventListener('pointermove', move);
|
||||||
|
document.removeEventListener('pointerup', up);
|
||||||
|
const finalSlot = liveOrder.indexOf(id);
|
||||||
|
me.classList.remove('dc-dragging');
|
||||||
|
me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`;
|
||||||
|
// After the settle transition, kill transitions + clear transforms +
|
||||||
|
// commit the reorder in the same frame so there's no visual snap-back.
|
||||||
|
setTimeout(() => {
|
||||||
|
for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; }
|
||||||
|
if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder);
|
||||||
|
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||||
|
for (const h of homes) h.el.style.transition = '';
|
||||||
|
}));
|
||||||
|
}, 180);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointermove', move);
|
||||||
|
document.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
|
||||||
|
<div className="dc-labelrow" style={{ position: 'absolute', bottom: '100%', left: -4, marginBottom: 4, color: DC.label }}>
|
||||||
|
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
|
||||||
|
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
|
||||||
|
</div>
|
||||||
|
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
|
||||||
|
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="dc-expand" onClick={onFocus} onPointerDown={(e) => e.stopPropagation()} title="Focus">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
|
||||||
|
</button>
|
||||||
|
<div className="dc-card"
|
||||||
|
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
|
||||||
|
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inline rename — commits on blur or Enter.
|
||||||
|
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
|
||||||
|
const T = tag;
|
||||||
|
return (
|
||||||
|
<T className="dc-editable" contentEditable suppressContentEditableWarning
|
||||||
|
onClick={onClick}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
|
||||||
|
style={style}>{value}</T>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across
|
||||||
|
// sections, Esc or backdrop click to exit.
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) {
|
||||||
|
const ctx = React.useContext(DCCtx);
|
||||||
|
const { sectionId, artboard } = entry;
|
||||||
|
const sec = ctx.section(sectionId);
|
||||||
|
const meta = sectionMeta[sectionId];
|
||||||
|
const peers = meta.slotIds;
|
||||||
|
const aid = artboard.props.id ?? artboard.props.label;
|
||||||
|
const idx = peers.indexOf(aid);
|
||||||
|
const secIdx = sectionOrder.indexOf(sectionId);
|
||||||
|
|
||||||
|
const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); };
|
||||||
|
const goSection = (d) => {
|
||||||
|
const ns = sectionOrder[(secIdx + d + sectionOrder.length) % sectionOrder.length];
|
||||||
|
const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0];
|
||||||
|
if (first) ctx.setFocus(`${ns}/${first}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const k = (e) => {
|
||||||
|
if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); }
|
||||||
|
if (e.key === 'ArrowRight') { e.preventDefault(); go(1); }
|
||||||
|
if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); }
|
||||||
|
if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); }
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', k);
|
||||||
|
return () => document.removeEventListener('keydown', k);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { width = 260, height = 480, children } = artboard.props;
|
||||||
|
const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight });
|
||||||
|
React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []);
|
||||||
|
const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2));
|
||||||
|
|
||||||
|
const [ddOpen, setDd] = React.useState(false);
|
||||||
|
const Arrow = ({ dir, onClick }) => (
|
||||||
|
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
|
||||||
|
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
|
||||||
|
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
|
||||||
|
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Portal to body so position:fixed is the real viewport regardless of any
|
||||||
|
// transform on DesignCanvas's ancestors (including the canvas zoom itself).
|
||||||
|
return ReactDOM.createPortal(
|
||||||
|
<div onClick={() => ctx.setFocus(null)}
|
||||||
|
onWheel={(e) => e.preventDefault()}
|
||||||
|
style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)',
|
||||||
|
fontFamily: DC.font, color: '#fff' }}>
|
||||||
|
|
||||||
|
{/* top bar: section dropdown (left) · close (right) */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<button onClick={() => setDd((o) => !o)}
|
||||||
|
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
|
||||||
|
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
|
||||||
|
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
|
||||||
|
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
|
||||||
|
</span>
|
||||||
|
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
|
||||||
|
</button>
|
||||||
|
{ddOpen && (
|
||||||
|
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
|
||||||
|
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
|
||||||
|
{sectionOrder.map((sid) => (
|
||||||
|
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
|
||||||
|
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
|
||||||
|
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
|
||||||
|
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
|
||||||
|
{sectionMeta[sid].title}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1 }} />
|
||||||
|
<button onClick={() => ctx.setFocus(null)}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||||
|
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
|
||||||
|
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* card centered, label + index below — only the card itself stops
|
||||||
|
propagation so any backdrop click (including the margins around
|
||||||
|
the card) exits focus */}
|
||||||
|
<div
|
||||||
|
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
|
||||||
|
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
|
||||||
|
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
|
||||||
|
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
|
||||||
|
{(sec.labels || {})[aid] ?? artboard.props.label}
|
||||||
|
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Arrow dir="left" onClick={() => go(-1)} />
|
||||||
|
<Arrow dir="right" onClick={() => go(1)} />
|
||||||
|
|
||||||
|
{/* dots */}
|
||||||
|
<div onClick={(e) => e.stopPropagation()}
|
||||||
|
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
|
||||||
|
{peers.map((p, i) => (
|
||||||
|
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
|
||||||
|
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
|
||||||
|
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
// Post-it — absolute-positioned sticky note
|
||||||
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', top, left, right, bottom, width,
|
||||||
|
background: DC.postitBg, padding: '14px 16px',
|
||||||
|
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
|
||||||
|
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
|
||||||
|
transform: `rotate(${rotate}deg)`,
|
||||||
|
zIndex: 5,
|
||||||
|
}}>{children}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
|
||||||
|
|
||||||
425
Prototype/tweaks-panel.jsx
Normal file
425
Prototype/tweaks-panel.jsx
Normal file
|
|
@ -0,0 +1,425 @@
|
||||||
|
|
||||||
|
// tweaks-panel.jsx
|
||||||
|
// Reusable Tweaks shell + form-control helpers.
|
||||||
|
//
|
||||||
|
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
|
||||||
|
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
|
||||||
|
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
|
||||||
|
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
|
||||||
|
//
|
||||||
|
// Usage (in an HTML file that loads React + Babel):
|
||||||
|
//
|
||||||
|
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
|
||||||
|
// "primaryColor": "#D97757",
|
||||||
|
// "fontSize": 16,
|
||||||
|
// "density": "regular",
|
||||||
|
// "dark": false
|
||||||
|
// }/*EDITMODE-END*/;
|
||||||
|
//
|
||||||
|
// function App() {
|
||||||
|
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
|
||||||
|
// return (
|
||||||
|
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
|
||||||
|
// Hello
|
||||||
|
// <TweaksPanel>
|
||||||
|
// <TweakSection label="Typography" />
|
||||||
|
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
|
||||||
|
// onChange={(v) => setTweak('fontSize', v)} />
|
||||||
|
// <TweakRadio label="Density" value={t.density}
|
||||||
|
// options={['compact', 'regular', 'comfy']}
|
||||||
|
// onChange={(v) => setTweak('density', v)} />
|
||||||
|
// <TweakSection label="Theme" />
|
||||||
|
// <TweakColor label="Primary" value={t.primaryColor}
|
||||||
|
// onChange={(v) => setTweak('primaryColor', v)} />
|
||||||
|
// <TweakToggle label="Dark mode" value={t.dark}
|
||||||
|
// onChange={(v) => setTweak('dark', v)} />
|
||||||
|
// </TweaksPanel>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const __TWEAKS_STYLE = `
|
||||||
|
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
|
||||||
|
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
|
||||||
|
background:rgba(250,249,247,.78);color:#29261b;
|
||||||
|
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
|
||||||
|
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
|
||||||
|
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
|
||||||
|
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
|
||||||
|
.twk-hd{display:flex;align-items:center;justify-content:space-between;
|
||||||
|
padding:10px 8px 10px 14px;cursor:move;user-select:none}
|
||||||
|
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
|
||||||
|
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
|
||||||
|
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
|
||||||
|
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
|
||||||
|
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
|
||||||
|
overflow-y:auto;overflow-x:hidden;min-height:0;
|
||||||
|
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
|
||||||
|
.twk-body::-webkit-scrollbar{width:8px}
|
||||||
|
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
|
||||||
|
border:2px solid transparent;background-clip:content-box}
|
||||||
|
.twk-row{display:flex;flex-direction:column;gap:5px}
|
||||||
|
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
|
||||||
|
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
|
||||||
|
color:rgba(41,38,27,.72)}
|
||||||
|
.twk-lbl>span:first-child{font-weight:500}
|
||||||
|
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
|
||||||
|
|
||||||
|
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
|
||||||
|
color:rgba(41,38,27,.45);padding:10px 0 0}
|
||||||
|
.twk-sect:first-child{padding-top:0}
|
||||||
|
|
||||||
|
.twk-field{appearance:none;width:100%;height:26px;padding:0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
|
||||||
|
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
|
||||||
|
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
|
||||||
|
select.twk-field{padding-right:22px;
|
||||||
|
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
|
||||||
|
background-repeat:no-repeat;background-position:right 8px center}
|
||||||
|
|
||||||
|
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
|
||||||
|
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
|
||||||
|
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
|
||||||
|
width:14px;height:14px;border-radius:50%;background:#fff;
|
||||||
|
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
|
||||||
|
|
||||||
|
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
|
||||||
|
background:rgba(0,0,0,.06);user-select:none}
|
||||||
|
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
|
||||||
|
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
|
||||||
|
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
|
||||||
|
.twk-seg.dragging .twk-seg-thumb{transition:none}
|
||||||
|
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
|
||||||
|
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
|
||||||
|
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
|
||||||
|
overflow-wrap:anywhere}
|
||||||
|
|
||||||
|
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
|
||||||
|
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
|
||||||
|
.twk-toggle[data-on="1"]{background:#34c759}
|
||||||
|
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
|
||||||
|
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
|
||||||
|
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
|
||||||
|
|
||||||
|
.twk-num{display:flex;align-items:center;height:26px;padding:0 0 0 8px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
|
||||||
|
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
|
||||||
|
user-select:none;padding-right:8px}
|
||||||
|
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
|
||||||
|
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
|
||||||
|
outline:none;color:inherit;-moz-appearance:textfield}
|
||||||
|
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
|
||||||
|
-webkit-appearance:none;margin:0}
|
||||||
|
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
|
||||||
|
|
||||||
|
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
|
||||||
|
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
|
||||||
|
.twk-btn:hover{background:rgba(0,0,0,.88)}
|
||||||
|
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
|
||||||
|
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
|
||||||
|
|
||||||
|
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
|
||||||
|
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
|
||||||
|
background:transparent;flex-shrink:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
|
||||||
|
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// ── useTweaks ───────────────────────────────────────────────────────────────
|
||||||
|
// Single source of truth for tweak values. setTweak persists via the host
|
||||||
|
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
|
||||||
|
function useTweaks(defaults) {
|
||||||
|
const [values, setValues] = React.useState(defaults);
|
||||||
|
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
|
||||||
|
// useState-style call doesn't write a "[object Object]" key into the persisted
|
||||||
|
// JSON block.
|
||||||
|
const setTweak = React.useCallback((keyOrEdits, val) => {
|
||||||
|
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
|
||||||
|
? keyOrEdits : { [keyOrEdits]: val };
|
||||||
|
setValues((prev) => ({ ...prev, ...edits }));
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
|
||||||
|
}, []);
|
||||||
|
return [values, setTweak];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TweaksPanel ─────────────────────────────────────────────────────────────
|
||||||
|
// Floating shell. Registers the protocol listener BEFORE announcing
|
||||||
|
// availability — if the announce ran first, the host's activate could land
|
||||||
|
// before our handler exists and the toolbar toggle would silently no-op.
|
||||||
|
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
|
||||||
|
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
|
||||||
|
// is what actually hides the panel.
|
||||||
|
function TweaksPanel({ title = 'Tweaks', children }) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const dragRef = React.useRef(null);
|
||||||
|
const offsetRef = React.useRef({ x: 16, y: 16 });
|
||||||
|
const PAD = 16;
|
||||||
|
|
||||||
|
const clampToViewport = React.useCallback(() => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const w = panel.offsetWidth, h = panel.offsetHeight;
|
||||||
|
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
|
||||||
|
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
|
||||||
|
offsetRef.current = {
|
||||||
|
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
|
||||||
|
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
|
||||||
|
};
|
||||||
|
panel.style.right = offsetRef.current.x + 'px';
|
||||||
|
panel.style.bottom = offsetRef.current.y + 'px';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
clampToViewport();
|
||||||
|
if (typeof ResizeObserver === 'undefined') {
|
||||||
|
window.addEventListener('resize', clampToViewport);
|
||||||
|
return () => window.removeEventListener('resize', clampToViewport);
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(clampToViewport);
|
||||||
|
ro.observe(document.documentElement);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [open, clampToViewport]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onMsg = (e) => {
|
||||||
|
const t = e?.data?.type;
|
||||||
|
if (t === '__activate_edit_mode') setOpen(true);
|
||||||
|
else if (t === '__deactivate_edit_mode') setOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('message', onMsg);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
|
||||||
|
return () => window.removeEventListener('message', onMsg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
setOpen(false);
|
||||||
|
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDragStart = (e) => {
|
||||||
|
const panel = dragRef.current;
|
||||||
|
if (!panel) return;
|
||||||
|
const r = panel.getBoundingClientRect();
|
||||||
|
const sx = e.clientX, sy = e.clientY;
|
||||||
|
const startRight = window.innerWidth - r.right;
|
||||||
|
const startBottom = window.innerHeight - r.bottom;
|
||||||
|
const move = (ev) => {
|
||||||
|
offsetRef.current = {
|
||||||
|
x: startRight - (ev.clientX - sx),
|
||||||
|
y: startBottom - (ev.clientY - sy),
|
||||||
|
};
|
||||||
|
clampToViewport();
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('mousemove', move);
|
||||||
|
window.removeEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', move);
|
||||||
|
window.addEventListener('mouseup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{__TWEAKS_STYLE}</style>
|
||||||
|
<div ref={dragRef} className="twk-panel"
|
||||||
|
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
|
||||||
|
<div className="twk-hd" onMouseDown={onDragStart}>
|
||||||
|
<b>{title}</b>
|
||||||
|
<button className="twk-x" aria-label="Close tweaks"
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={dismiss}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="twk-body">{children}</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Layout helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSection({ label, children }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="twk-sect">{label}</div>
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRow({ label, value, children, inline = false }) {
|
||||||
|
return (
|
||||||
|
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
|
||||||
|
<div className="twk-lbl">
|
||||||
|
<span>{label}</span>
|
||||||
|
{value != null && <span className="twk-val">{value}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Controls ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label} value={`${value}${unit}`}>
|
||||||
|
<input type="range" className="twk-slider" min={min} max={max} step={step}
|
||||||
|
value={value} onChange={(e) => onChange(Number(e.target.value))} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakToggle({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
|
||||||
|
role="switch" aria-checked={!!value}
|
||||||
|
onClick={() => onChange(!value)}><i /></button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakRadio({ label, value, options, onChange }) {
|
||||||
|
const trackRef = React.useRef(null);
|
||||||
|
const [dragging, setDragging] = React.useState(false);
|
||||||
|
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
|
||||||
|
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
|
||||||
|
const n = opts.length;
|
||||||
|
|
||||||
|
// The active value is read by pointer-move handlers attached for the lifetime
|
||||||
|
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
|
||||||
|
const valueRef = React.useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
const segAt = (clientX) => {
|
||||||
|
const r = trackRef.current.getBoundingClientRect();
|
||||||
|
const inner = r.width - 4;
|
||||||
|
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
|
||||||
|
return opts[Math.max(0, Math.min(n - 1, i))].value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e) => {
|
||||||
|
setDragging(true);
|
||||||
|
const v0 = segAt(e.clientX);
|
||||||
|
if (v0 !== valueRef.current) onChange(v0);
|
||||||
|
const move = (ev) => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const v = segAt(ev.clientX);
|
||||||
|
if (v !== valueRef.current) onChange(v);
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
setDragging(false);
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
|
||||||
|
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
|
||||||
|
<div className="twk-seg-thumb"
|
||||||
|
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
|
||||||
|
width: `calc((100% - 4px) / ${n})` }} />
|
||||||
|
{opts.map((o) => (
|
||||||
|
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
|
||||||
|
{o.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakSelect({ label, value, options, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const v = typeof o === 'object' ? o.value : o;
|
||||||
|
const l = typeof o === 'object' ? o.label : o;
|
||||||
|
return <option key={v} value={v}>{l}</option>;
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakText({ label, value, placeholder, onChange }) {
|
||||||
|
return (
|
||||||
|
<TweakRow label={label}>
|
||||||
|
<input className="twk-field" type="text" value={value} placeholder={placeholder}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</TweakRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
|
||||||
|
const clamp = (n) => {
|
||||||
|
if (min != null && n < min) return min;
|
||||||
|
if (max != null && n > max) return max;
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startRef = React.useRef({ x: 0, val: 0 });
|
||||||
|
const onScrubStart = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
startRef.current = { x: e.clientX, val: value };
|
||||||
|
const decimals = (String(step).split('.')[1] || '').length;
|
||||||
|
const move = (ev) => {
|
||||||
|
const dx = ev.clientX - startRef.current.x;
|
||||||
|
const raw = startRef.current.val + dx * step;
|
||||||
|
const snapped = Math.round(raw / step) * step;
|
||||||
|
onChange(clamp(Number(snapped.toFixed(decimals))));
|
||||||
|
};
|
||||||
|
const up = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', up);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="twk-num">
|
||||||
|
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
|
||||||
|
<input type="number" value={value} min={min} max={max} step={step}
|
||||||
|
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
|
||||||
|
{unit && <span className="twk-num-unit">{unit}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakColor({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="twk-row twk-row-h">
|
||||||
|
<div className="twk-lbl"><span>{label}</span></div>
|
||||||
|
<input type="color" className="twk-swatch" value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TweakButton({ label, onClick, secondary = false }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
|
||||||
|
onClick={onClick}>{label}</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
useTweaks, TweaksPanel, TweakSection, TweakRow,
|
||||||
|
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
|
||||||
|
TweakText, TweakNumber, TweakColor, TweakButton,
|
||||||
|
});
|
||||||
237
Spec/01-design-document.md
Normal file
237
Spec/01-design-document.md
Normal file
|
|
@ -0,0 +1,237 @@
|
||||||
|
# Pelagia Portal — Design Document
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
Pelagia Portal is an internal purchase order (PO) management web application for a maritime / vessel-operations company. It digitises the entire PO lifecycle — from a crew member raising a requisition, through manager approval and vendor validation, to accounts payment processing and final receipt confirmation — replacing ad-hoc email chains and spreadsheets with a single, auditable system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals & Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
- Provide role-specific dashboards and workflows so every actor only sees what is relevant to their job.
|
||||||
|
- Enforce a structured, auditable approval chain for every purchase order.
|
||||||
|
- Notify all stakeholders at each state transition via email without manual action.
|
||||||
|
- Give management real-time spend visibility by vessel, project, and time period.
|
||||||
|
- Surface vendor information deficiencies before payment is blocked.
|
||||||
|
|
||||||
|
### Non-Goals
|
||||||
|
- Direct integration with external accounting or ERP software (out of scope for v1).
|
||||||
|
- Mobile-native apps (the web app is expected to be accessed on desktop/tablet).
|
||||||
|
- Supplier-facing self-service portal.
|
||||||
|
- Automated payment processing (Accounts team confirms payment manually).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Actors & Roles
|
||||||
|
|
||||||
|
| Role | Description | Key Permissions |
|
||||||
|
|---|---|---|
|
||||||
|
| **Technical** | Deck / engine crew raising POs for technical vessel needs | Create, edit draft, submit, confirm receipt |
|
||||||
|
| **Manning** | Crew-management staff raising POs for manning / crew needs | Same as Technical |
|
||||||
|
| **Manager** | Approves or rejects POs; can request edits, add vendor IDs, or directly amend line items during review | Review, approve, reject, request edits, edit line items (versioned), view all POs, history reports |
|
||||||
|
| **Accounts** | Processes payment for approved POs | View payment queue, mark as paid, view all POs |
|
||||||
|
| **SuperUser** | Elevated user with cross-team operational authority | All Technical + Manning + Manager permissions |
|
||||||
|
| **Auditor** | Read-only audit access across all records | View all POs, download audit trail, export reports |
|
||||||
|
| **Admin** | System administrator | Manage users, vessels, accounts, vendors; full CRUD on all entities |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. PO Lifecycle & State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT ──(submit)──► SUBMITTED ──(system auto-move)──► MGR_REVIEW
|
||||||
|
│
|
||||||
|
┌──────────────────────────────────────┤
|
||||||
|
│ │ │
|
||||||
|
(no vendor ID) (request edits) (reject)
|
||||||
|
▼ ▼ ▼
|
||||||
|
VENDOR_ID_PENDING EDITS_REQUESTED REJECTED
|
||||||
|
│ │
|
||||||
|
(ID provided) (resubmit)
|
||||||
|
└────────────────────┘
|
||||||
|
│
|
||||||
|
(approve / approve+note)
|
||||||
|
▼
|
||||||
|
MGR_APPROVED
|
||||||
|
│
|
||||||
|
(accounts picks up)
|
||||||
|
▼
|
||||||
|
SENT_FOR_PAYMENT
|
||||||
|
│
|
||||||
|
(payment confirmed)
|
||||||
|
▼
|
||||||
|
PAID_DELIVERED
|
||||||
|
│
|
||||||
|
(submitter confirms receipt)
|
||||||
|
▼
|
||||||
|
CLOSED
|
||||||
|
```
|
||||||
|
|
||||||
|
### Allowed State Transitions
|
||||||
|
|
||||||
|
| From | To | Actor | Trigger |
|
||||||
|
|---|---|---|---|
|
||||||
|
| DRAFT | SUBMITTED | Technical / Manning / SuperUser | Submit button |
|
||||||
|
| SUBMITTED | MGR_REVIEW | System | Auto on submit |
|
||||||
|
| MGR_REVIEW | VENDOR_ID_PENDING | Manager | Missing vendor ID |
|
||||||
|
| VENDOR_ID_PENDING | MGR_REVIEW | Submitter / Manager | Vendor ID supplied |
|
||||||
|
| MGR_REVIEW | EDITS_REQUESTED | Manager | Request edits action |
|
||||||
|
| EDITS_REQUESTED | SUBMITTED | Technical / Manning / SuperUser | Resubmit after edits |
|
||||||
|
| MGR_REVIEW | REJECTED | Manager | Reject action |
|
||||||
|
| MGR_REVIEW | MGR_APPROVED | Manager / SuperUser | Approve or Approve+Note |
|
||||||
|
| MGR_APPROVED | SENT_FOR_PAYMENT | Accounts | Pick up payment |
|
||||||
|
| SENT_FOR_PAYMENT | PAID_DELIVERED | Accounts | Confirm payment |
|
||||||
|
| PAID_DELIVERED | CLOSED | Technical / Manning / SuperUser | Confirm receipt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Email Notification Matrix
|
||||||
|
|
||||||
|
| Event | Notified Parties |
|
||||||
|
|---|---|
|
||||||
|
| PO submitted | Manager(s), Submitter (confirmation) |
|
||||||
|
| Vendor ID requested | Submitter |
|
||||||
|
| Vendor ID supplied | Manager |
|
||||||
|
| Edits requested | Submitter (includes manager note) |
|
||||||
|
| PO resubmitted after edits | Manager |
|
||||||
|
| PO approved | Submitter, Accounts (with PO attachment) |
|
||||||
|
| PO approved with note | Submitter (with note), Accounts |
|
||||||
|
| PO rejected | Submitter (with rejection reason) |
|
||||||
|
| Payment sent | Submitter, Manager |
|
||||||
|
| Receipt confirmed | Manager, Accounts |
|
||||||
|
| PO closed | Submitter, Manager, Accounts |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Screen Inventory
|
||||||
|
|
||||||
|
### 6.1 Authentication
|
||||||
|
- **Login** — Employee ID / email + password. Role badge hints displayed. No self-registration; accounts provisioned by Admin.
|
||||||
|
|
||||||
|
### 6.2 Dashboards (role-specific landing pages)
|
||||||
|
- **Technical / Manning Dashboard** — My open POs count, pending approvals, completed orders, quick-access "New PO" CTA. Full list of all POs (open and historical) is accessible and each PO is openable from the dashboard.
|
||||||
|
- **Manager Dashboard** — Approvals awaiting count, approved POs listing with per-PO expense breakdown (line items + totals), spend by vessel (bar chart), spend by month (bar chart), recent activity feed.
|
||||||
|
- **Accounts Dashboard** — Payment queue total value, ready-for-payment item count, recently processed items.
|
||||||
|
|
||||||
|
### 6.3 PO Creation & Editing
|
||||||
|
- **New PO Form** — Multi-section form:
|
||||||
|
- Order Info: title, vessel, account, project code, date required
|
||||||
|
- Line Items: add / remove rows (description, qty, unit, unit price, total)
|
||||||
|
- Vendor: vendor name, vendor ID (optional at creation), contact
|
||||||
|
- Documents: drag-and-drop upload, file list with remove
|
||||||
|
- Approval Flow: read-only visual showing who will review
|
||||||
|
- **Edit PO** — Same form, pre-populated; only available when PO is in DRAFT or EDITS_REQUESTED.
|
||||||
|
|
||||||
|
### 6.4 Manager Approval
|
||||||
|
- **Approval Queue** — Paginated list with search (PO number, vessel, submitter) and filters (date range, vessel). Each row shows PO number, submitter, vessel, amount, days waiting.
|
||||||
|
- **PO Detail / Decision View** — Full PO summary, line items, attached documents, vendor info with verification callout (NEW if no ID). 4-action bar: Reject | Request Edits | Approve | Approve + Note.
|
||||||
|
|
||||||
|
### 6.5 Accounts Payment Queue
|
||||||
|
- **Payment Queue** — Approved POs ready for payment. Shows PO summary, total amount, bank / payment ref fields. "Mark as Paid" action.
|
||||||
|
|
||||||
|
### 6.6 Order Tracking (Submitter)
|
||||||
|
- **My Orders** — Card list with live status indicator, progress step-bar, latest manager note, and "Confirm Receipt" CTA when in PAID_DELIVERED.
|
||||||
|
|
||||||
|
### 6.7 Receipt Confirmation
|
||||||
|
- **Receipt Screen** — Upload receipt / invoice image, delivery confirmation checklist, optional notes. Submits to close the PO.
|
||||||
|
|
||||||
|
### 6.8 Manager History / Reports
|
||||||
|
- **History** — Full PO audit list with date, submitter, vessel, status, amount. Export to CSV / PDF. Filter by date range, vessel, status.
|
||||||
|
|
||||||
|
### 6.9 Administration (Admin role)
|
||||||
|
- **User Management** — CRUD for user accounts, role assignment.
|
||||||
|
- **Vessel Management** — CRUD for vessels.
|
||||||
|
- **Account Management** — CRUD for accounts / cost centres.
|
||||||
|
- **Vendor Management** — CRUD for approved vendor registry.
|
||||||
|
- **Product Catalogue** — CRUD for products: product code, name, description. Last known unit price and associated vendor are read-only in this view — they are auto-populated when a PO containing that product is marked as paid.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Design System
|
||||||
|
|
||||||
|
### 7.1 Colour Palette
|
||||||
|
|
||||||
|
| Token | Hex | Usage |
|
||||||
|
|---|---|---|
|
||||||
|
| `primary` | `#2563EB` | Primary actions, active states, links |
|
||||||
|
| `primary-dark` | `#1D4ED8` | Hover on primary |
|
||||||
|
| `success` | `#16A34A` | Approved, paid, closed states |
|
||||||
|
| `warning` | `#D97706` | Pending review, edits requested |
|
||||||
|
| `danger` | `#DC2626` | Rejected, destructive actions |
|
||||||
|
| `neutral-50` | `#F9FAFB` | Page background |
|
||||||
|
| `neutral-100` | `#F3F4F6` | Card / panel background |
|
||||||
|
| `neutral-700` | `#374151` | Body text |
|
||||||
|
| `neutral-900` | `#111827` | Headings |
|
||||||
|
|
||||||
|
### 7.2 Typography
|
||||||
|
|
||||||
|
| Element | Font | Weight | Size |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Headings (H1–H3) | Inter | 600–700 | 24 / 20 / 16 px |
|
||||||
|
| Body | Inter | 400 | 14 px |
|
||||||
|
| Labels / captions | Inter | 500 | 12 px |
|
||||||
|
| Data / mono values | JetBrains Mono | 400 | 13 px |
|
||||||
|
|
||||||
|
### 7.3 Component Conventions
|
||||||
|
- Cards use `rounded-lg`, `shadow-sm`, 16 px padding.
|
||||||
|
- Status badges use pill shape with colour-coded background matching state machine colours.
|
||||||
|
- Tables use alternating row shading, sticky header on scroll.
|
||||||
|
- Forms use floating labels; validation errors appear below the field in `danger` colour.
|
||||||
|
- Action buttons: primary = blue fill, secondary = white with border, danger = red fill.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. User Stories (Priority P0 = must-have, P1 = should-have, P2 = nice-to-have)
|
||||||
|
|
||||||
|
### Submitter (Technical / Manning)
|
||||||
|
| ID | Story | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| S-01 | As a submitter, I can create a PO with line items and attach documents. | P0 |
|
||||||
|
| S-02 | As a submitter, I can save a PO as draft before submitting. | P0 |
|
||||||
|
| S-03 | As a submitter, I can submit a draft PO for manager approval. | P0 |
|
||||||
|
| S-04 | As a submitter, I receive an email when my PO is approved or rejected. | P0 |
|
||||||
|
| S-05 | As a submitter, I can view the current status and history of all my POs. | P0 |
|
||||||
|
| S-06 | As a submitter, I can provide a vendor ID when requested by a manager. | P0 |
|
||||||
|
| S-07 | As a submitter, I can edit and resubmit a PO when edits are requested. | P0 |
|
||||||
|
| S-08 | As a submitter, I can confirm receipt and upload a receipt document to close a PO. | P0 |
|
||||||
|
|
||||||
|
### Manager
|
||||||
|
| ID | Story | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| M-01 | As a manager, I see all POs awaiting my approval in a queue. | P0 |
|
||||||
|
| M-02 | As a manager, I can approve, reject, or request edits on a PO. | P0 |
|
||||||
|
| M-03 | As a manager, I can add a note when approving or rejecting. | P0 |
|
||||||
|
| M-04 | As a manager, I can flag a PO for vendor ID verification. | P0 |
|
||||||
|
| M-05 | As a manager, I can view spend analytics by vessel and month. | P1 |
|
||||||
|
| M-06 | As a manager, I can export a full PO history report as CSV or PDF. | P1 |
|
||||||
|
|
||||||
|
### Accounts
|
||||||
|
| ID | Story | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| A-01 | As an accounts user, I see all manager-approved POs ready for payment. | P0 |
|
||||||
|
| A-02 | As an accounts user, I can mark a PO as paid with a reference number. | P0 |
|
||||||
|
| A-03 | As an accounts user, I receive email when a new PO enters my payment queue. | P0 |
|
||||||
|
|
||||||
|
### Admin
|
||||||
|
| ID | Story | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| AD-01 | As an admin, I can create, edit, and deactivate user accounts. | P0 |
|
||||||
|
| AD-02 | As an admin, I can manage vessels, accounts, and vendors. | P0 |
|
||||||
|
| AD-03 | As an admin, I can manage the product catalogue (codes, names, descriptions). Last known prices and vendors are automatically updated when a PO is paid. | P1 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Accessibility & Internationalisation
|
||||||
|
- WCAG 2.1 AA compliance target.
|
||||||
|
- All interactive elements keyboard-navigable with visible focus ring.
|
||||||
|
- Colour is never the sole conveyor of meaning (icons + labels accompany status colours).
|
||||||
|
- English only for v1; i18n architecture (react-i18next) to be wired up but not populated.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Open Questions
|
||||||
|
- Should managers be able to directly edit a PO (bypass submitter) in exceptional circumstances?
|
||||||
|
- What is the approval chain for high-value POs — single manager or dual sign-off?
|
||||||
|
- Should the vendor registry be editable by managers, or Admin-only?
|
||||||
|
- Is SSO (e.g., Azure AD) required for login, or internal credential management is sufficient?
|
||||||
564
Spec/02-architecture.md
Normal file
564
Spec/02-architecture.md
Normal file
|
|
@ -0,0 +1,564 @@
|
||||||
|
# Pelagia Portal — Architecture Document
|
||||||
|
|
||||||
|
## 1. Technology Stack
|
||||||
|
|
||||||
|
### 1.1 Decision Summary
|
||||||
|
|
||||||
|
The portal is an internal line-of-business app with a well-defined data model, multi-role access, and transactional workflows. The stack below optimises for **developer velocity**, **type safety end-to-end**, and **operational simplicity** (minimal infrastructure to manage).
|
||||||
|
|
||||||
|
| Layer | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| **Framework** | Next.js 15 (App Router) | Full-stack React; server components reduce client JS; built-in API routes; excellent TypeScript support |
|
||||||
|
| **Language** | TypeScript 5 (strict mode) | Shared types between frontend and backend; catches contract mismatches at compile time |
|
||||||
|
| **UI Library** | React 19 | Concurrent rendering, Server Components |
|
||||||
|
| **Component Library** | shadcn/ui + Radix UI primitives | Accessible, unstyled primitives; copy-owned source, no black-box upgrade surprises |
|
||||||
|
| **Styling** | Tailwind CSS v4 | Utility-first; consistent design tokens; no CSS specificity battles |
|
||||||
|
| **ORM** | Prisma 5 | Type-safe DB client; schema-first migrations; Prisma Studio for admin data inspection |
|
||||||
|
| **Database** | PostgreSQL 16 | ACID transactions; JSON columns for flexible line-item metadata; mature RBAC at row level |
|
||||||
|
| **Auth** | NextAuth.js v5 (Auth.js) | Session-cookie auth; credentials provider for internal accounts; easy SSO adapter upgrade path |
|
||||||
|
| **File Storage** | Cloudflare R2 (S3-compatible) in production; local filesystem in development | Cheap egress; S3 API compatibility; presigned URLs keep uploads off the app server; dev mode avoids paid services |
|
||||||
|
| **Email** | Resend + React Email in production; console log in development | Transactional email with React-rendered templates; generous free tier; reliable deliverability; dev mode requires no API key |
|
||||||
|
| **Charts** | Recharts | Lightweight; composable; works well with server-fetched data in RSC |
|
||||||
|
| **Validation** | Zod | Schema validation shared between server actions and client form validation |
|
||||||
|
| **Testing** | Vitest + React Testing Library + Playwright | Unit/integration fast with Vitest; E2E critical paths with Playwright |
|
||||||
|
| **CI/CD** | GitHub Actions | Lint, type-check, test, build on every PR; deploy on merge to main |
|
||||||
|
| **Hosting** | Vercel (app) + Supabase (Postgres + Storage fallback) | Zero-config deploys; Vercel serverless functions match Next.js well |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. High-Level System Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ React 19 + shadcn/ui + Tailwind │
|
||||||
|
│ Server Components (read) + Client Components (forms) │
|
||||||
|
└──────────────────┬──────────────────────────────────────┘
|
||||||
|
│ HTTPS
|
||||||
|
┌──────────────────▼──────────────────────────────────────┐
|
||||||
|
│ Next.js 15 App Server │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────────┐ │
|
||||||
|
│ │ App Router Pages │ │ Server Actions / API │ │
|
||||||
|
│ │ (RSC rendering) │ │ Route Handlers │ │
|
||||||
|
│ └─────────────────────┘ └──────────┬──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────────────────────────────▼──────────────┐ │
|
||||||
|
│ │ Business Logic Layer │ │
|
||||||
|
│ │ (PO state machine, permission checks, notifier) │ │
|
||||||
|
│ └──────────────────────┬────────────────────────────┘ │
|
||||||
|
└─────────────────────────┼────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌───────────────┼───────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌─────────▼────┐ ┌───────▼──────┐ ┌────▼──────────┐
|
||||||
|
│ PostgreSQL │ │ Cloudflare R2│ │ Resend │
|
||||||
|
│ (Prisma) │ │ (documents, │ │ (transact- │
|
||||||
|
│ │ │ receipts) │ │ ional email) │
|
||||||
|
└──────────────┘ └──────────────┘ └───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Application Layer Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
pelagia-portal/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── (auth)/
|
||||||
|
│ │ └── login/
|
||||||
|
│ ├── (portal)/ # Authenticated shell
|
||||||
|
│ │ ├── layout.tsx # Sidebar + header shell
|
||||||
|
│ │ ├── dashboard/
|
||||||
|
│ │ ├── po/
|
||||||
|
│ │ │ ├── new/
|
||||||
|
│ │ │ ├── [id]/
|
||||||
|
│ │ │ │ ├── page.tsx # Detail view
|
||||||
|
│ │ │ │ └── edit/
|
||||||
|
│ │ ├── approvals/
|
||||||
|
│ │ ├── payments/
|
||||||
|
│ │ ├── history/
|
||||||
|
│ │ └── admin/
|
||||||
|
│ │ ├── users/
|
||||||
|
│ │ ├── vessels/
|
||||||
|
│ │ ├── accounts/
|
||||||
|
│ │ └── vendors/
|
||||||
|
│ └── api/
|
||||||
|
│ ├── auth/[...nextauth]/
|
||||||
|
│ └── files/
|
||||||
|
│ ├── sign/ # Generate presigned upload URL (production)
|
||||||
|
│ └── dev/[...key]/ # Local file upload/download handler (dev only)
|
||||||
|
│
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # shadcn/ui primitives (owned copies)
|
||||||
|
│ ├── po/ # PO-specific composite components
|
||||||
|
│ ├── dashboard/
|
||||||
|
│ └── layout/
|
||||||
|
│
|
||||||
|
├── lib/
|
||||||
|
│ ├── db.ts # Prisma client singleton
|
||||||
|
│ ├── auth.ts # NextAuth config
|
||||||
|
│ ├── po-state-machine.ts # State transition logic + guards
|
||||||
|
│ ├── permissions.ts # Role → allowed-action map
|
||||||
|
│ ├── notifier.ts # Email dispatch (wraps Resend)
|
||||||
|
│ ├── storage.ts # R2 presigned URL helpers
|
||||||
|
│ └── validations/ # Zod schemas
|
||||||
|
│
|
||||||
|
├── emails/ # React Email templates
|
||||||
|
│ ├── po-submitted.tsx
|
||||||
|
│ ├── po-approved.tsx
|
||||||
|
│ ├── po-rejected.tsx
|
||||||
|
│ ├── edits-requested.tsx
|
||||||
|
│ ├── vendor-id-needed.tsx
|
||||||
|
│ ├── payment-processed.tsx
|
||||||
|
│ └── receipt-confirmed.tsx
|
||||||
|
│
|
||||||
|
├── prisma/
|
||||||
|
│ ├── schema.prisma
|
||||||
|
│ └── migrations/
|
||||||
|
│
|
||||||
|
└── tests/
|
||||||
|
├── unit/
|
||||||
|
├── integration/
|
||||||
|
└── e2e/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Data Model
|
||||||
|
|
||||||
|
### 4.1 Entity Relationship (Prisma Schema)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
// prisma/schema.prisma
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
TECHNICAL
|
||||||
|
MANNING
|
||||||
|
ACCOUNTS
|
||||||
|
MANAGER
|
||||||
|
SUPERUSER
|
||||||
|
AUDITOR
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum POStatus {
|
||||||
|
DRAFT
|
||||||
|
SUBMITTED
|
||||||
|
MGR_REVIEW
|
||||||
|
VENDOR_ID_PENDING
|
||||||
|
EDITS_REQUESTED
|
||||||
|
REJECTED
|
||||||
|
MGR_APPROVED
|
||||||
|
SENT_FOR_PAYMENT
|
||||||
|
PAID_DELIVERED
|
||||||
|
CLOSED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ActionType {
|
||||||
|
CREATED
|
||||||
|
SUBMITTED
|
||||||
|
APPROVED
|
||||||
|
APPROVED_WITH_NOTE
|
||||||
|
REJECTED
|
||||||
|
EDITS_REQUESTED
|
||||||
|
VENDOR_ID_REQUESTED
|
||||||
|
VENDOR_ID_PROVIDED
|
||||||
|
PAYMENT_SENT
|
||||||
|
RECEIPT_CONFIRMED
|
||||||
|
CLOSED
|
||||||
|
REASSIGNED
|
||||||
|
PRODUCT_PRICE_UPDATED
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
employeeId String @unique
|
||||||
|
email String @unique
|
||||||
|
name String
|
||||||
|
passwordHash String
|
||||||
|
role Role
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
submittedPOs PurchaseOrder[] @relation("Submitter")
|
||||||
|
actions POAction[]
|
||||||
|
notifications Notification[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Vessel {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
imoNumber String? @unique
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Vendor {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
name String
|
||||||
|
vendorId String? @unique
|
||||||
|
contactName String?
|
||||||
|
contactEmail String?
|
||||||
|
isVerified Boolean @default(false)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
purchaseOrders PurchaseOrder[]
|
||||||
|
products Product[] @relation("ProductLastVendor")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Product {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
code String @unique
|
||||||
|
name String
|
||||||
|
description String?
|
||||||
|
lastPrice Decimal? @db.Decimal(12, 2)
|
||||||
|
lastVendorId String?
|
||||||
|
lastVendor Vendor? @relation("ProductLastVendor", fields: [lastVendorId], references: [id])
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
lineItems POLineItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model PurchaseOrder {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
poNumber String @unique @default(cuid()) // formatted in app layer
|
||||||
|
title String
|
||||||
|
status POStatus @default(DRAFT)
|
||||||
|
totalAmount Decimal @db.Decimal(12, 2)
|
||||||
|
currency String @default("USD")
|
||||||
|
dateRequired DateTime?
|
||||||
|
projectCode String?
|
||||||
|
managerNote String?
|
||||||
|
paymentRef String?
|
||||||
|
submittedAt DateTime?
|
||||||
|
approvedAt DateTime?
|
||||||
|
paidAt DateTime?
|
||||||
|
closedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
submitterId String
|
||||||
|
submitter User @relation("Submitter", fields: [submitterId], references: [id])
|
||||||
|
vesselId String
|
||||||
|
vessel Vessel @relation(fields: [vesselId], references: [id])
|
||||||
|
accountId String
|
||||||
|
account Account @relation(fields: [accountId], references: [id])
|
||||||
|
vendorId String?
|
||||||
|
vendor Vendor? @relation(fields: [vendorId], references: [id])
|
||||||
|
|
||||||
|
lineItems POLineItem[]
|
||||||
|
documents PODocument[]
|
||||||
|
actions POAction[]
|
||||||
|
receipt Receipt?
|
||||||
|
notifications Notification[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model POLineItem {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
description String
|
||||||
|
quantity Decimal @db.Decimal(10, 3)
|
||||||
|
unit String
|
||||||
|
unitPrice Decimal @db.Decimal(12, 2)
|
||||||
|
totalPrice Decimal @db.Decimal(12, 2)
|
||||||
|
sortOrder Int @default(0)
|
||||||
|
productId String?
|
||||||
|
product Product? @relation(fields: [productId], references: [id])
|
||||||
|
|
||||||
|
poId String
|
||||||
|
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model PODocument {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
fileName String
|
||||||
|
fileSize Int
|
||||||
|
mimeType String
|
||||||
|
storageKey String // R2 object key
|
||||||
|
uploadedAt DateTime @default(now())
|
||||||
|
|
||||||
|
poId String
|
||||||
|
po PurchaseOrder @relation(fields: [poId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model POAction {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
actionType ActionType
|
||||||
|
note String?
|
||||||
|
metadata Json? // flexible: payment ref, vendor ID, etc.
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
poId String
|
||||||
|
po PurchaseOrder @relation(fields: [poId], references: [id])
|
||||||
|
actorId String
|
||||||
|
actor User @relation(fields: [actorId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Receipt {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
storageKey String // R2 object key
|
||||||
|
fileName String
|
||||||
|
notes String?
|
||||||
|
confirmedAt DateTime @default(now())
|
||||||
|
|
||||||
|
poId String @unique
|
||||||
|
po PurchaseOrder @relation(fields: [poId], references: [id])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Notification {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
subject String
|
||||||
|
body String
|
||||||
|
sentAt DateTime @default(now())
|
||||||
|
status String @default("sent") // sent | failed | bounced
|
||||||
|
|
||||||
|
poId String?
|
||||||
|
po PurchaseOrder? @relation(fields: [poId], references: [id])
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Authentication & Authorisation
|
||||||
|
|
||||||
|
### 5.1 Authentication
|
||||||
|
- Session-cookie based via NextAuth.js v5, `CredentialsProvider`.
|
||||||
|
- Passwords hashed with bcrypt (cost factor 12).
|
||||||
|
- Sessions stored server-side (database adapter); JWT not used to avoid stale role tokens.
|
||||||
|
- Session contains: `userId`, `role`, `name`, `email`.
|
||||||
|
|
||||||
|
### 5.2 Authorisation Model
|
||||||
|
Role permissions are enforced in a central `lib/permissions.ts` module and checked in Server Actions / Route Handlers before any data mutation. React Server Components also gate entire page segments server-side.
|
||||||
|
|
||||||
|
```
|
||||||
|
Action | Technical | Manning | Accounts | Manager | SuperUser | Auditor | Admin
|
||||||
|
----------------------------|-----------|---------|----------|---------|-----------|---------|-------
|
||||||
|
create_po | ✓ | ✓ | | | ✓ | |
|
||||||
|
submit_po | ✓ | ✓ | | | ✓ | |
|
||||||
|
edit_own_draft_po | ✓ | ✓ | | | ✓ | |
|
||||||
|
view_own_pos | ✓ | ✓ | | | ✓ | ✓ | ✓
|
||||||
|
view_all_pos | | | ✓ | ✓ | ✓ | ✓ | ✓
|
||||||
|
approve_po | | | | ✓ | ✓ | |
|
||||||
|
reject_po | | | | ✓ | ✓ | |
|
||||||
|
request_edits | | | | ✓ | ✓ | |
|
||||||
|
request_vendor_id | | | | ✓ | ✓ | |
|
||||||
|
process_payment | | | ✓ | | | |
|
||||||
|
confirm_receipt | ✓ | ✓ | | | ✓ | |
|
||||||
|
view_analytics | | | | ✓ | ✓ | ✓ | ✓
|
||||||
|
export_reports | | | | ✓ | ✓ | ✓ | ✓
|
||||||
|
manage_users | | | | | | | ✓
|
||||||
|
manage_vendors | | | | | | | ✓
|
||||||
|
manage_vessels_accounts | | | | | | | ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PO State Machine Implementation
|
||||||
|
|
||||||
|
The state machine lives entirely in `lib/po-state-machine.ts`. No state transition may be performed without going through this module, ensuring the graph is enforced in one place.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// lib/po-state-machine.ts (illustrative)
|
||||||
|
|
||||||
|
export type POStatus =
|
||||||
|
| 'DRAFT' | 'SUBMITTED' | 'MGR_REVIEW' | 'VENDOR_ID_PENDING'
|
||||||
|
| 'EDITS_REQUESTED' | 'REJECTED' | 'MGR_APPROVED'
|
||||||
|
| 'SENT_FOR_PAYMENT' | 'PAID_DELIVERED' | 'CLOSED';
|
||||||
|
|
||||||
|
interface Transition {
|
||||||
|
to: POStatus;
|
||||||
|
allowedRoles: Role[];
|
||||||
|
requiresNote?: boolean;
|
||||||
|
sideEffects: SideEffect[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transitions: Record<POStatus, Record<string, Transition>> = {
|
||||||
|
DRAFT: {
|
||||||
|
submit: { to: 'SUBMITTED', allowedRoles: ['TECHNICAL','MANNING','SUPERUSER'], sideEffects: ['EMAIL_MANAGER'] },
|
||||||
|
},
|
||||||
|
MGR_REVIEW: {
|
||||||
|
approve: { to: 'MGR_APPROVED', allowedRoles: ['MANAGER','SUPERUSER'], sideEffects: ['EMAIL_SUBMITTER','EMAIL_ACCOUNTS'] },
|
||||||
|
approve_note: { to: 'MGR_APPROVED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER','EMAIL_ACCOUNTS'] },
|
||||||
|
reject: { to: 'REJECTED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER'] },
|
||||||
|
request_edits: { to: 'EDITS_REQUESTED', allowedRoles: ['MANAGER','SUPERUSER'], requiresNote: true, sideEffects: ['EMAIL_SUBMITTER'] },
|
||||||
|
request_vendor: { to: 'VENDOR_ID_PENDING', allowedRoles: ['MANAGER','SUPERUSER'], sideEffects: ['EMAIL_SUBMITTER'] },
|
||||||
|
},
|
||||||
|
SENT_FOR_PAYMENT: {
|
||||||
|
confirm_payment: { to: 'PAID_DELIVERED', allowedRoles: ['ACCOUNTS'], sideEffects: ['EMAIL_SUBMITTER','EMAIL_MANAGER','UPDATE_PRODUCT_PRICES'] },
|
||||||
|
},
|
||||||
|
// ...
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canTransition(from: POStatus, action: string, role: Role): boolean { ... }
|
||||||
|
export async function applyTransition(poId: string, action: string, actor: User, note?: string): Promise<PurchaseOrder> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Product Price Auto-Update (`UPDATE_PRODUCT_PRICES` side effect)
|
||||||
|
|
||||||
|
When `confirm_payment` fires on a `SENT_FOR_PAYMENT` PO, `applyTransition` iterates every line item that carries a `productId`. For each one it sets `Product.lastPrice = lineItem.unitPrice` and `Product.lastVendorId = po.vendorId`. A `PRODUCT_PRICE_UPDATED` `POAction` is logged per updated product. Line items without a `productId` are skipped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File Upload Flow
|
||||||
|
|
||||||
|
To avoid routing large files through the app server, uploads use **presigned URLs** in production. Development uses a local equivalent to avoid requiring Cloudflare credentials.
|
||||||
|
|
||||||
|
**Production (`NODE_ENV=production`) — Cloudflare R2:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Client App Server Cloudflare R2
|
||||||
|
│ │ │
|
||||||
|
│── POST /api/files/sign ──►│ │
|
||||||
|
│ { fileName, mimeType } │ │
|
||||||
|
│ │── generate presigned ─►│
|
||||||
|
│ │◄─── presigned URL ─────│
|
||||||
|
│◄── { uploadUrl, key } ────│ │
|
||||||
|
│ │ │
|
||||||
|
│─────── PUT uploadUrl ──────────────────────────────►│
|
||||||
|
│ │ │
|
||||||
|
│── Server Action: link ───►│ │
|
||||||
|
│ { poId, key, meta } │── INSERT PODocument ──►│ (DB)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Development (`NODE_ENV=development`) — local filesystem:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Client App Server .dev-uploads/
|
||||||
|
│ │ │
|
||||||
|
│── POST /api/files/sign ──►│ │
|
||||||
|
│ { fileName, mimeType } │ │
|
||||||
|
│◄── { uploadUrl, key } ────│ │
|
||||||
|
│ uploadUrl = /api/files/dev/<key> │
|
||||||
|
│ │ │
|
||||||
|
│── PUT /api/files/dev/<key>►│ │
|
||||||
|
│ │── write to disk ───────►│
|
||||||
|
│ │ │
|
||||||
|
│── Server Action: link ───►│ │
|
||||||
|
│ { poId, key, meta } │── INSERT PODocument ──►│ (DB)
|
||||||
|
```
|
||||||
|
|
||||||
|
Downloads follow the same pattern: `generateDownloadUrl` returns a `/api/files/dev/<key>` GET URL in development and an R2 presigned URL in production. The `app/api/files/dev/[...key]/route.ts` route is auth-gated and returns 404 in production.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Notification System
|
||||||
|
|
||||||
|
`lib/notifier.ts` is the single point for dispatching emails. It is called exclusively from within state-machine side-effects, never directly from UI handlers.
|
||||||
|
|
||||||
|
```
|
||||||
|
notifier.notify({
|
||||||
|
event: 'PO_APPROVED',
|
||||||
|
po: PurchaseOrder, // full PO with relations
|
||||||
|
recipients: User[], // resolved from event matrix
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**In production**, email templates live in `/emails/` as React Email components, rendered server-side with `@react-email/render` and sent via the Resend SDK.
|
||||||
|
|
||||||
|
**In development**, the email content (recipient, subject, body) is printed to the terminal instead of being sent. No Resend API key is required.
|
||||||
|
|
||||||
|
In both modes, all notification events are persisted in the `Notification` table for audit purposes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. API Surface
|
||||||
|
|
||||||
|
All data mutations are implemented as **Next.js Server Actions** (no separate REST endpoints for mutations). Queries use React Server Components where possible; client components call `fetch` against route handlers only for dynamic/paginated data.
|
||||||
|
|
||||||
|
| Route Handler | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/auth/[...nextauth]` | GET/POST | Auth.js session endpoints |
|
||||||
|
| `/api/files/sign` | POST | Generate R2 presigned upload URL |
|
||||||
|
| `/api/po/[id]/export` | GET | Export single PO as PDF |
|
||||||
|
| `/api/reports/export` | GET | Export history report as CSV/PDF |
|
||||||
|
|
||||||
|
All other data operations (create PO, approve, reject, etc.) are Server Actions in `app/(portal)/*/actions.ts` co-located with their page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Deployment Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────┐
|
||||||
|
│ Vercel │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────┐ │
|
||||||
|
│ │ Next.js App (Edge + Node.js) │ │
|
||||||
|
│ │ - Static assets via Vercel CDN │ │
|
||||||
|
│ │ - Server Components on Node.js runtime │ │
|
||||||
|
│ │ - API routes / Server Actions │ │
|
||||||
|
│ └──────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
┌────────▼──────┐ ┌────────▼──────────────┐
|
||||||
|
│ Supabase │ │ Cloudflare R2 │
|
||||||
|
│ PostgreSQL │ │ (document storage) │
|
||||||
|
│ (managed, │ │ │
|
||||||
|
│ auto-backup)│ └────────────────────────┘
|
||||||
|
└───────────────┘
|
||||||
|
│
|
||||||
|
┌────────▼──────┐
|
||||||
|
│ Resend │
|
||||||
|
│ (email API) │
|
||||||
|
└───────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
The set of required variables differs between development and production. The switch is automatic — controlled by `NODE_ENV` (set to `development` by `next dev` and `production` by `next build/start`).
|
||||||
|
|
||||||
|
| Variable | Dev Required | Prod Required | Notes |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `NEXTAUTH_SECRET` | Yes | Yes | 32-char random secret |
|
||||||
|
| `NEXTAUTH_URL` | Yes | Yes | Full app URL |
|
||||||
|
| `DATABASE_URL` | Yes | Yes | PostgreSQL connection string |
|
||||||
|
| `R2_ACCOUNT_ID` | No | Yes | Cloudflare account ID |
|
||||||
|
| `R2_ACCESS_KEY_ID` | No | Yes | R2 access key |
|
||||||
|
| `R2_SECRET_ACCESS_KEY` | No | Yes | R2 secret key |
|
||||||
|
| `R2_BUCKET_NAME` | No | Yes | R2 bucket name |
|
||||||
|
| `R2_PUBLIC_URL` | No | Yes | Public R2 bucket URL |
|
||||||
|
| `RESEND_API_KEY` | No | Yes | Resend API key |
|
||||||
|
| `EMAIL_FROM` | No | Yes | Sender address |
|
||||||
|
| `EMAIL_FROM_NAME` | No | No | Display name (default: "Pelagia Portal") |
|
||||||
|
|
||||||
|
In development, uploaded files are stored in `.dev-uploads/` at the project root and emails are printed to the terminal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Testing Strategy
|
||||||
|
|
||||||
|
| Layer | Tool | What is tested |
|
||||||
|
|---|---|---|
|
||||||
|
| Unit | Vitest | State machine transitions, permission checks, Zod validators, utility functions |
|
||||||
|
| Integration | Vitest + Prisma test DB | Server Actions against a real test database; seeded with fixture data |
|
||||||
|
| E2E | Playwright | Full happy paths per role: create PO → approve → pay → confirm receipt |
|
||||||
|
| Accessibility | axe-core + Playwright | WCAG violations on key pages |
|
||||||
|
|
||||||
|
CI runs all tests on every pull request. Playwright E2E runs against a preview deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Development Conventions
|
||||||
|
|
||||||
|
- **Branch strategy**: `main` (production) ← `staging` ← feature branches (`feat/`, `fix/`, `chore/`).
|
||||||
|
- **Commit style**: Conventional Commits (`feat:`, `fix:`, `refactor:`).
|
||||||
|
- **Code quality**: ESLint (Next.js config) + Prettier + TypeScript strict mode; enforced via husky pre-commit hook.
|
||||||
|
- **Database migrations**: Never edit `schema.prisma` without generating and committing a migration (`prisma migrate dev`). Migration files are committed and reviewed in PRs.
|
||||||
|
- **Secrets**: Never committed; managed via Vercel environment variable UI and `.env.local` locally (`.env.local` is git-ignored).
|
||||||
16
Spec/03-open-questions.md
Normal file
16
Spec/03-open-questions.md
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
# Pelagia Portal — Open Questions & Decisions Log
|
||||||
|
|
||||||
|
Track decisions that need sign-off before the corresponding feature is built. Update the Status column when resolved.
|
||||||
|
|
||||||
|
| # | Question | Raised By | Status | Decision |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Should a manager be able to directly edit a PO (bypass the submitter edit cycle) in exceptional circumstances? | Design review | Open | — |
|
||||||
|
| 2 | Is dual sign-off required for POs above a certain value threshold? If so, what is the threshold and how is the second approver selected? | Design review | Open | — |
|
||||||
|
| 3 | Is the vendor registry Admin-only, or can Managers also add/edit vendors? | Design review | Open | — |
|
||||||
|
| 4 | Is SSO (Azure AD / Google Workspace) required for login, or is internal credential management sufficient for v1? | Architecture review | Open | — |
|
||||||
|
| 5 | What currency / currencies does the system need to support? Is multi-currency (with FX rates) in scope? | Design review | Open | — |
|
||||||
|
| 6 | Should rejected POs be hard-deleted after a retention period or permanently archived? How long is the retention window? | Legal / compliance | Open | — |
|
||||||
|
| 7 | Should documents (PO attachments, receipts) be publicly accessible via URL, or always served through a signed/authenticated download? | Security review | Open | — |
|
||||||
|
| 8 | Are there specific vessels or accounts that certain submitters are restricted to (i.e., row-level vessel permissions), or is any submitter able to raise a PO against any vessel? | Design review | Open | — |
|
||||||
|
| 9 | What is the expected volume? (POs per day, concurrent users) — affects connection-pool sizing and whether Vercel serverless is sufficient. | Architecture review | Open | — |
|
||||||
|
| 10 | Should Manager analytics (spend by vessel/month) include only CLOSED POs, or all POs from MGR_APPROVED onwards? | Design review | Open | — |
|
||||||
264
Spec/TEST_PLAN.md
Normal file
264
Spec/TEST_PLAN.md
Normal file
|
|
@ -0,0 +1,264 @@
|
||||||
|
# Pelagia Portal — Test Plan
|
||||||
|
|
||||||
|
**Version:** 1.0
|
||||||
|
**Date:** 2026-05-09
|
||||||
|
**Project:** Pelagia Marine Services PO Portal
|
||||||
|
**Scope:** Unit, Integration, and E2E test coverage across all portal features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
This document describes the testing strategy, scope, tooling, and coverage matrix for the Pelagia Portal. It is intended as the authoritative reference for what is tested, why, and how to run each layer.
|
||||||
|
|
||||||
|
The portal manages the full lifecycle of purchase orders: creation, submission, manager review, vendor assignment, payment, and receipt confirmation. Testing focuses on correctness of state transitions, permission enforcement, and data integrity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Testing Stack
|
||||||
|
|
||||||
|
| Layer | Tool | Environment | Command |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Unit | Vitest 2.x | jsdom | `pnpm test` |
|
||||||
|
| Integration | Vitest 2.x | Node (real DB) | `pnpm test:integration` |
|
||||||
|
| E2E | Playwright 1.49 | Chromium (dev server) | `pnpm test:e2e` |
|
||||||
|
|
||||||
|
**Key libraries:** `@testing-library/react`, `@testing-library/jest-dom`, `@testing-library/user-event`.
|
||||||
|
|
||||||
|
Unit tests live in `tests/unit/`. Integration tests live in `tests/integration/`. E2E specs live in `tests/e2e/`.
|
||||||
|
|
||||||
|
Integration tests run serially in a single fork (`poolOptions.forks.singleFork = true`) to avoid database conflicts. Each test suite cleans up its own data via `afterEach` using the `deletePosByTitle(PREFIX)` helper.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Test Data & Environment
|
||||||
|
|
||||||
|
### 3.1 Seeded Data (prisma/seed.ts)
|
||||||
|
|
||||||
|
| Entity | Records | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Users | 5 | admin, manager, tech, accounts, manning |
|
||||||
|
| Vessels | 3 | MV Pelagia Star, MV Aegean Wind, MV Poseidon |
|
||||||
|
| Accounts | 3 | TECH-OPS, CREW-MGT, FUEL-BNK |
|
||||||
|
| Vendors | 12 | VND-0001 to VND-0012; VND-0003 and VND-0012 are unverified |
|
||||||
|
| Products | 25 | Spanning lubricants, filters, safety, rope, electrical, paint, navigation |
|
||||||
|
|
||||||
|
Re-run with `npx tsx prisma/seed.ts` before integration tests if the database is reset.
|
||||||
|
|
||||||
|
### 3.2 Authentication Mocking
|
||||||
|
|
||||||
|
Integration tests mock `@/auth` to inject a session without real credentials:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||||
|
vi.mocked(auth).mockResolvedValue(makeSession(userId, "MANAGER"));
|
||||||
|
```
|
||||||
|
|
||||||
|
`makeSession(userId, role)` is defined in `tests/integration/helpers.ts`.
|
||||||
|
|
||||||
|
### 3.3 Side-Effect Mocking
|
||||||
|
|
||||||
|
All integration and unit tests mock:
|
||||||
|
- `@/lib/notifier` — prevents email dispatch
|
||||||
|
- `next/cache` (`revalidatePath`) — avoids Next.js cache calls outside a server context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Coverage Matrix
|
||||||
|
|
||||||
|
### 4.1 Unit Tests
|
||||||
|
|
||||||
|
| File | Test File | Cases Covered |
|
||||||
|
|---|---|---|
|
||||||
|
| `lib/permissions.ts` | `tests/unit/permissions.test.ts` | All 7 roles × key permissions; `requirePermission` throws |
|
||||||
|
| `lib/po-state-machine.ts` | `tests/unit/po-state-machine.test.ts` | `canPerformAction`, `getTransition`, `requiresNote`, `getAvailableActions`; MANAGER/ACCOUNTS expansions |
|
||||||
|
| `lib/po-import-parser.ts` | `tests/unit/po-import-parser.test.ts` | `cellStr`, `cellNum`, `parseSheet` (real + synthetic), `parseWorkbook` |
|
||||||
|
| `lib/validations/po.ts` | `tests/unit/validations.test.ts` | `lineItemSchema`, `createPoSchema`, TC defaults |
|
||||||
|
| `components/po/po-line-items-editor.tsx` | `tests/unit/po-line-items-editor.test.tsx` | Edit mode, read-only mode, totals, add/remove |
|
||||||
|
| `components/po/po-status-badge.tsx` | `tests/unit/po-status-badge.test.tsx` | All status labels |
|
||||||
|
| `lib/utils.ts` | `tests/unit/utils.test.ts` | `formatCurrency`, `formatDate`, `generatePoNumber`, status maps |
|
||||||
|
|
||||||
|
### 4.2 Integration Tests
|
||||||
|
|
||||||
|
| Test File | Feature | Scenarios |
|
||||||
|
|---|---|---|
|
||||||
|
| `create-po.test.ts` | S-01, S-02, S-03 | Draft, submit, line items, totals, optional fields, notifications |
|
||||||
|
| `approval-actions.test.ts` | M-02, M-03, M-04, S-06, S-07 | Approve, reject, request edits, vendor ID flow, resubmit |
|
||||||
|
| `payment-actions.test.ts` | A-01, A-02 | Payment queue, mark paid |
|
||||||
|
| `discard-po.test.ts` | Discard draft | Owner, MANAGER, SUPERUSER can discard; ACCOUNTS and non-owners denied; status guard; cascade cleanup |
|
||||||
|
| `vendor-approval.test.ts` | Vendor gate + provide vendor ID | Approval blocked without vendor; ACCOUNTS can provide vendor ID; unverified vendor rejected; AUDITOR denied |
|
||||||
|
| `manager-po-creation.test.ts` | Manager creates POs | MANAGER can create, submit, discard; ACCOUNTS denied; role documented for self-approval |
|
||||||
|
| `products-search.test.ts` | Product search API | Auth, min-length validation, name/code/description search, case-insensitive, max 10, inactive excluded, Decimal serialised |
|
||||||
|
| `import-api.test.ts` | Excel import API | Auth (TECHNICAL/ACCOUNTS → 403), no file, invalid file, correct parse of Sample_PO.xlsx |
|
||||||
|
|
||||||
|
### 4.3 E2E Tests (Playwright)
|
||||||
|
|
||||||
|
| Spec File | Scenarios |
|
||||||
|
|---|---|
|
||||||
|
| `auth.spec.ts` | Login, redirect on bad creds, role badge, sign-out |
|
||||||
|
| `submitter-journey.spec.ts` | Create draft, add line items, submit, see status transitions |
|
||||||
|
| `manager-approvals.spec.ts` | Review PO, approve with/without note, reject, request edits |
|
||||||
|
| `accounts-payment.spec.ts` | Payment queue, process payment, confirm receipt |
|
||||||
|
| `po-export.spec.ts` | PDF and XLSX export buttons and content |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Permission Test Matrix
|
||||||
|
|
||||||
|
The table below documents every role's expected access to key operations. ✓ = allowed, ✗ = denied.
|
||||||
|
|
||||||
|
| Operation | TECHNICAL | MANNING | ACCOUNTS | MANAGER | SUPERUSER | AUDITOR | ADMIN |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| Create PO | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Submit PO | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Edit own draft | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Discard own draft | ✓ | ✓ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Discard any draft | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Approve PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Reject PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Request edits | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Provide vendor ID | Own PO only | Own PO only | ✓ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| Process payment | ✗ | ✗ | ✓ | ✗ | ✓ | ✗ | ✗ |
|
||||||
|
| Confirm receipt | Own PO only | Own PO only | ✗ | ✗ | ✓ | ✗ | ✗ |
|
||||||
|
| Manage vendors | ✗ | ✗ | ✓ | ✓ | ✗ | ✗ | ✓ |
|
||||||
|
| Manage products | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✓ |
|
||||||
|
| Import PO | ✗ | ✗ | ✗ | ✓ | ✓ | ✗ | ✓ |
|
||||||
|
| View analytics | ✗ | ✗ | ✗ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
|
||||||
|
**Business rules tested explicitly:**
|
||||||
|
- A vendor must be assigned before a manager can approve a PO.
|
||||||
|
- Only verified vendors (those with a `vendorId` field) may be assigned via `provideVendorId`.
|
||||||
|
- Discarding is only possible on `DRAFT` status POs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Feature-Level Test Scenarios
|
||||||
|
|
||||||
|
### F-01: PO Creation & Draft Management
|
||||||
|
| ID | Scenario | Type | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S-01 | Create PO with multiple line items; verify totals | Integration | `create-po.test.ts` |
|
||||||
|
| S-02 | Save as draft; verify status = DRAFT | Integration | `create-po.test.ts` |
|
||||||
|
| S-02a | ACCOUNTS role denied creation | Integration | `create-po.test.ts` |
|
||||||
|
| S-02b | MANAGER can create and save a draft | Integration | `manager-po-creation.test.ts` |
|
||||||
|
| S-03 | Submit for approval; status = MGR_REVIEW | Integration | `create-po.test.ts` |
|
||||||
|
| S-04 | Discard draft by owner | Integration | `discard-po.test.ts` |
|
||||||
|
| S-04a | MANAGER discards any draft | Integration | `discard-po.test.ts` |
|
||||||
|
| S-04b | ACCOUNTS cannot discard | Integration | `discard-po.test.ts` |
|
||||||
|
| S-04c | Cannot discard a submitted PO | Integration | `discard-po.test.ts` |
|
||||||
|
|
||||||
|
### F-02: Approval Workflow
|
||||||
|
| ID | Scenario | Type | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M-01 | Manager sees pending POs | E2E | `manager-approvals.spec.ts` |
|
||||||
|
| M-02 | Approve PO → MGR_APPROVED | Integration / E2E | `approval-actions.test.ts` |
|
||||||
|
| M-02a | Approve with note stores managerNote | Integration | `approval-actions.test.ts` |
|
||||||
|
| M-02b | Approval blocked — no vendor assigned | Integration | `vendor-approval.test.ts` |
|
||||||
|
| M-03 | Reject PO with note | Integration / E2E | `approval-actions.test.ts` |
|
||||||
|
| M-04 | Request edits → EDITS_REQUESTED | Integration | `approval-actions.test.ts` |
|
||||||
|
| M-04a | Request vendor ID → VENDOR_ID_PENDING | Integration | `approval-actions.test.ts` |
|
||||||
|
| M-04b | TECHNICAL denied approval | Integration | `approval-actions.test.ts` |
|
||||||
|
|
||||||
|
### F-03: Vendor ID Assignment
|
||||||
|
| ID | Scenario | Type | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| S-06 | TECHNICAL provides vendor ID on own PO | Integration | `approval-actions.test.ts` |
|
||||||
|
| S-06a | ACCOUNTS provides vendor ID | Integration | `vendor-approval.test.ts` |
|
||||||
|
| S-06b | Unverified vendor rejected | Integration | `vendor-approval.test.ts` |
|
||||||
|
| S-06c | AUDITOR cannot provide vendor ID | Integration | `vendor-approval.test.ts` |
|
||||||
|
| S-06d | Wrong status → error | Integration | `vendor-approval.test.ts` |
|
||||||
|
|
||||||
|
### F-04: Payment & Receipt
|
||||||
|
| ID | Scenario | Type | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| A-01 | Accounts processes payment | Integration / E2E | `payment-actions.test.ts` |
|
||||||
|
| A-02 | Mark as paid with reference | Integration / E2E | `payment-actions.test.ts` |
|
||||||
|
|
||||||
|
### F-05: Excel Import
|
||||||
|
| ID | Scenario | Type | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| I-01 | Parser extracts 1 line item from Sample_PO.xlsx | Unit | `po-import-parser.test.ts` |
|
||||||
|
| I-02 | T&C rows not included in line items | Unit | `po-import-parser.test.ts` |
|
||||||
|
| I-03 | Vendor name, PI quotation, place of delivery extracted | Unit | `po-import-parser.test.ts` |
|
||||||
|
| I-04 | GST rate > 1 normalised to fraction | Unit | `po-import-parser.test.ts` |
|
||||||
|
| I-05 | INSTRUCTIONS TO VENDORS row stops parsing | Unit | `po-import-parser.test.ts` |
|
||||||
|
| I-06 | TECHNICAL / ACCOUNTS denied (403) | Integration | `import-api.test.ts` |
|
||||||
|
| I-07 | Unauthenticated denied (401) | Integration | `import-api.test.ts` |
|
||||||
|
| I-08 | No file → 400 | Integration | `import-api.test.ts` |
|
||||||
|
| I-09 | Invalid binary → 400 | Integration | `import-api.test.ts` |
|
||||||
|
| I-10 | MANAGER receives parsed results (200) | Integration | `import-api.test.ts` |
|
||||||
|
| I-11 | Correct line item values in API response | Integration | `import-api.test.ts` |
|
||||||
|
|
||||||
|
### F-06: Product Fuzzy Search
|
||||||
|
| ID | Scenario | Type | File |
|
||||||
|
|---|---|---|---|
|
||||||
|
| P-01 | Unauthenticated → 401 | Integration | `products-search.test.ts` |
|
||||||
|
| P-02 | Query < 2 chars → empty array | Integration | `products-search.test.ts` |
|
||||||
|
| P-03 | Search by name substring | Integration | `products-search.test.ts` |
|
||||||
|
| P-04 | Search by product code | Integration | `products-search.test.ts` |
|
||||||
|
| P-05 | Search by description text | Integration | `products-search.test.ts` |
|
||||||
|
| P-06 | Case-insensitive matching | Integration | `products-search.test.ts` |
|
||||||
|
| P-07 | Max 10 results returned | Integration | `products-search.test.ts` |
|
||||||
|
| P-08 | lastPrice serialised as `number` not Prisma Decimal | Integration | `products-search.test.ts` |
|
||||||
|
| P-09 | Inactive products excluded | Integration | `products-search.test.ts` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Known Gaps & Out-of-Scope Items
|
||||||
|
|
||||||
|
### Currently untested (acceptable gaps)
|
||||||
|
| Area | Reason |
|
||||||
|
|---|---|
|
||||||
|
| File upload to S3 / storage | Requires live AWS credentials; tested manually in staging |
|
||||||
|
| Email notification content | `notify()` is mocked; email body format tested via review |
|
||||||
|
| PDF/XLSX export content | Snapshot-tested manually; E2E checks endpoint responds |
|
||||||
|
| Receipt confirmation workflow | Happy path covered in E2E; integration test pending |
|
||||||
|
| Admin CRUD (users, vessels, accounts, products) | Standard CRUD; covered by E2E smoke tests |
|
||||||
|
|
||||||
|
### Out of scope
|
||||||
|
- Performance / load testing
|
||||||
|
- Accessibility (a11y) automated checks
|
||||||
|
- Cross-browser testing (Chromium only)
|
||||||
|
- Mobile viewport testing
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Running the Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All unit tests (fast, no DB needed)
|
||||||
|
pnpm test
|
||||||
|
|
||||||
|
# Unit tests in watch mode during development
|
||||||
|
pnpm test:watch
|
||||||
|
|
||||||
|
# Integration tests (requires seeded DB)
|
||||||
|
pnpm test:integration
|
||||||
|
|
||||||
|
# All unit + integration
|
||||||
|
pnpm test:all
|
||||||
|
|
||||||
|
# E2E tests (requires running dev server)
|
||||||
|
pnpm test:e2e
|
||||||
|
|
||||||
|
# E2E with interactive Playwright UI
|
||||||
|
pnpm test:e2e:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pre-requisites for integration tests
|
||||||
|
1. A PostgreSQL instance running and `.env` pointing to it (`DATABASE_URL`).
|
||||||
|
2. Schema applied: `npx prisma migrate deploy` (or `npx prisma db push` in dev).
|
||||||
|
3. Data seeded: `npx tsx prisma/seed.ts`.
|
||||||
|
|
||||||
|
### CI behaviour
|
||||||
|
Integration tests and E2E tests run on every PR. E2E tests retry twice on failure (`playwright.config.ts`). The `test:all` script is used for pre-merge validation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Test Authorship Conventions
|
||||||
|
|
||||||
|
- **Naming:** `describe` blocks map to feature scenarios (e.g., `"S-02 — save as draft"`). `it` blocks describe the outcome, not the action.
|
||||||
|
- **Prefix isolation:** Every integration test uses a `PREFIX` constant (e.g., `"INTTEST_DISCARD_"`) and cleans up with `afterEach(() => deletePosByTitle(PREFIX))`.
|
||||||
|
- **No test interdependence:** Each test creates its own data. Tests must pass in isolation and in any order.
|
||||||
|
- **Negative tests first:** Each describe block should include at least one negative (denial/error) case before or after the happy path.
|
||||||
|
- **Avoid `any`:** Type assertions in tests should use `as { id: string }` or similar narrow casts, not `as any`.
|
||||||
BIN
Spec/Untitled.png
Normal file
BIN
Spec/Untitled.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
719
design_handoff_pelagia_portal/DESIGN.md
Normal file
719
design_handoff_pelagia_portal/DESIGN.md
Normal file
|
|
@ -0,0 +1,719 @@
|
||||||
|
# Pelagia Portal — Design Document
|
||||||
|
|
||||||
|
Internal purchase-order management system for a maritime company.
|
||||||
|
This document describes every feature, page, workflow, and user story to guide UI/UX design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Purpose
|
||||||
|
|
||||||
|
Pelagia Portal digitises the full purchase-order lifecycle — from a crew member raising a requisition aboard a vessel, through manager approval and payment by accounts, to receipt confirmation on delivery. It replaces paper and email-based processes with a traceable, role-gated workflow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Roles
|
||||||
|
|
||||||
|
Seven roles exist. Each role represents a real job function in the company.
|
||||||
|
|
||||||
|
| Role | Who they are | Core capability |
|
||||||
|
|------|-------------|-----------------|
|
||||||
|
| **TECHNICAL** | Ship technical crew | Create, submit, and track their own POs; confirm delivery |
|
||||||
|
| **MANNING** | Manning crew | Same as TECHNICAL |
|
||||||
|
| **ACCOUNTS** | Finance / accounts team | Process payments, manage vendor registry |
|
||||||
|
| **MANAGER** | Department manager | Review and approve POs, edit line items before approval, view analytics |
|
||||||
|
| **SUPERUSER** | Power user / ops lead | All PO actions across the board |
|
||||||
|
| **AUDITOR** | Internal auditor | Read-only view of all POs; export reports |
|
||||||
|
| **ADMIN** | System administrator | Manage users, vendors, vessels, accounts, products, and sites |
|
||||||
|
|
||||||
|
### Role Access Matrix
|
||||||
|
|
||||||
|
| Feature area | TECH / MANNING | ACCOUNTS | MANAGER | SUPERUSER | AUDITOR | ADMIN |
|
||||||
|
|---|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||||
|
| Create / edit own POs | ✓ | | ✓ | ✓ | | |
|
||||||
|
| Approve / reject POs | | | ✓ | ✓ | | |
|
||||||
|
| Process payments | | ✓ | | ✓ | | |
|
||||||
|
| Confirm receipt | ✓ | | | ✓ | | |
|
||||||
|
| View all POs | | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| View analytics / export | | | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| Vendor registry | | ✓ | ✓ | | | ✓ |
|
||||||
|
| Item catalogue | | | ✓ | | | ✓ |
|
||||||
|
| Vessel management | | | ✓ | | | ✓ |
|
||||||
|
| Site management | | | ✓ | | | ✓ |
|
||||||
|
| User management | | | | | | ✓ |
|
||||||
|
| Account management | | | ✓ | | | ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Navigation Structure
|
||||||
|
|
||||||
|
The left sidebar adapts to the signed-in user's role.
|
||||||
|
|
||||||
|
```
|
||||||
|
Dashboard ← all users
|
||||||
|
|
||||||
|
─── Purchase Orders ──────────────────
|
||||||
|
New PO ← TECH, MANNING, MANAGER, SUPERUSER
|
||||||
|
My Orders ← TECH, MANNING, MANAGER, SUPERUSER
|
||||||
|
Approvals ← MANAGER, SUPERUSER
|
||||||
|
Import PO ← MANAGER, SUPERUSER, ADMIN
|
||||||
|
Payments ← ACCOUNTS
|
||||||
|
History / Export ← MANAGER, SUPERUSER, ACCOUNTS, AUDITOR, ADMIN
|
||||||
|
|
||||||
|
─── Inventory ───────────────────────
|
||||||
|
Vendors ← MANAGER, ACCOUNTS, ADMIN
|
||||||
|
Items ← MANAGER, ADMIN
|
||||||
|
Vessels ← MANAGER, ADMIN
|
||||||
|
Sites ← MANAGER, ADMIN
|
||||||
|
Cart ← TECH, MANNING, MANAGER, SUPERUSER
|
||||||
|
|
||||||
|
─── Administration ────────────────── (ADMIN only)
|
||||||
|
Users
|
||||||
|
Accounts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Authentication
|
||||||
|
|
||||||
|
### Login Page `/login`
|
||||||
|
|
||||||
|
- Email + password form
|
||||||
|
- Validates credentials against bcrypt hash
|
||||||
|
- On success: redirects to `/dashboard` (or pre-login destination)
|
||||||
|
- No self-registration; accounts are created by an ADMIN
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Page Catalogue
|
||||||
|
|
||||||
|
### 5.1 Dashboard `/dashboard`
|
||||||
|
|
||||||
|
Entry point after login. Content varies by role.
|
||||||
|
|
||||||
|
**Submitter view (TECHNICAL / MANNING / SUPERUSER)**
|
||||||
|
- Stat cards: Open orders count, Pending approval count, Completed orders
|
||||||
|
- Quick "New PO" call-to-action
|
||||||
|
- Link to full order list
|
||||||
|
|
||||||
|
**Manager view**
|
||||||
|
- Stat cards: Awaiting approval (clickable → approval queue), Approved this month, Total approved spend
|
||||||
|
- Recent approved POs table: PO number, title, vessel, amount, date
|
||||||
|
- Spend trend chart (monthly bar chart, last 6–12 months)
|
||||||
|
- Vessel spend breakdown chart (pie or bar)
|
||||||
|
|
||||||
|
**Accounts view**
|
||||||
|
- Stat cards: Ready for payment count, Total value awaiting payment
|
||||||
|
- Quick link to payment queue
|
||||||
|
|
||||||
|
**Auditor / Admin view**
|
||||||
|
- Total PO count with link to history
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.2 My Purchase Orders `/my-orders`
|
||||||
|
|
||||||
|
Personal PO list for submitters.
|
||||||
|
|
||||||
|
**Open orders table** (DRAFT, SUBMITTED, MGR_REVIEW, VENDOR_ID_PENDING, EDITS_REQUESTED)
|
||||||
|
- Columns: PO Number, Title, Vessel, Status badge, Amount, Last updated
|
||||||
|
- Manager note displayed inline if status = EDITS_REQUESTED
|
||||||
|
|
||||||
|
**Past orders table** (MGR_APPROVED through CLOSED / REJECTED)
|
||||||
|
- Same columns
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- "New PO" button (top right)
|
||||||
|
- Click any row → PO detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.3 Approval Queue `/approvals`
|
||||||
|
|
||||||
|
All POs awaiting manager decision (status = MGR_REVIEW).
|
||||||
|
|
||||||
|
Filter bar:
|
||||||
|
- Search (PO number, submitter name, title)
|
||||||
|
- Vessel dropdown
|
||||||
|
- Date from picker
|
||||||
|
|
||||||
|
Table columns: PO Number, Title, Submitter, Vessel, Amount, Submitted date
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
- "Review" link per row → approval detail page
|
||||||
|
- Pending count shown in heading
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.4 PO Detail `/po/[id]`
|
||||||
|
|
||||||
|
Full read view of a single PO. Accessible to: the submitter (own POs), ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN.
|
||||||
|
|
||||||
|
**Header band**
|
||||||
|
- PO number (monospace)
|
||||||
|
- Status badge (colour-coded)
|
||||||
|
- Export PDF button
|
||||||
|
|
||||||
|
**Body sections**
|
||||||
|
|
||||||
|
*Summary panel*
|
||||||
|
- Title, vessel, account, vendor (if assigned), project code, date required, currency, total amount
|
||||||
|
|
||||||
|
*Line items table*
|
||||||
|
- Columns: Item name, Description, Qty, Unit, Unit price, GST rate, Total (incl. GST)
|
||||||
|
- Read-only
|
||||||
|
|
||||||
|
*Terms & Conditions*
|
||||||
|
- Delivery, Dispatch, Inspection, Transit insurance, Payment terms, Others
|
||||||
|
|
||||||
|
*Documents*
|
||||||
|
- Uploaded files with download links
|
||||||
|
|
||||||
|
*Audit trail*
|
||||||
|
- Chronological list of every action on the PO
|
||||||
|
- Each row: actor name, action type, timestamp, optional note
|
||||||
|
|
||||||
|
*Timestamps sidebar (or footer)*
|
||||||
|
- Created, Submitted, Approved, Paid, Closed
|
||||||
|
|
||||||
|
**Contextual action buttons** (shown/hidden based on status and role)
|
||||||
|
|
||||||
|
| Condition | Button |
|
||||||
|
|-----------|--------|
|
||||||
|
| Status = DRAFT or EDITS_REQUESTED + own submitter | Edit |
|
||||||
|
| Status = DRAFT + own submitter or MANAGER/SUPERUSER | Discard (delete draft) |
|
||||||
|
| Status = VENDOR_ID_PENDING + can provide vendor | Vendor selection form inline |
|
||||||
|
| Status = PAID_DELIVERED + own submitter or SUPERUSER | Confirm Receipt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.5 Approval Detail `/approvals/[id]`
|
||||||
|
|
||||||
|
Full PO view with approval action panel. MANAGER / SUPERUSER only.
|
||||||
|
|
||||||
|
Same content as PO detail, plus:
|
||||||
|
|
||||||
|
**Manager action panel**
|
||||||
|
- Approve button
|
||||||
|
- Approve with Note button (opens note textarea, then approves)
|
||||||
|
- Reject button (requires mandatory note)
|
||||||
|
- Request Edits button (requires mandatory note)
|
||||||
|
- Request Vendor ID button (sends back to submitter to supply vendor)
|
||||||
|
|
||||||
|
**Manager line-item edit form**
|
||||||
|
- Inline form allowing manager to adjust quantities, unit prices, GST rate, add/remove line items and change vessel, account, vendor before approving
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.6 New PO `/po/new`
|
||||||
|
|
||||||
|
Multi-section form to create a purchase order.
|
||||||
|
|
||||||
|
**Section 1 — Header**
|
||||||
|
- Title (required)
|
||||||
|
- Description / remarks
|
||||||
|
- Vessel (required, dropdown)
|
||||||
|
- Account / Cost Centre (required, dropdown)
|
||||||
|
- Vendor (optional, dropdown — can be added later)
|
||||||
|
- Date Required (date picker)
|
||||||
|
- Project Code
|
||||||
|
|
||||||
|
**Section 2 — Line Items**
|
||||||
|
- Dynamic table; rows can be added and removed
|
||||||
|
- Per-row fields: Name (searchable against item catalogue), Description, Qty, Unit, Size, Unit Price, GST Rate
|
||||||
|
- As-you-type name search shows matching products with per-vendor prices as hints
|
||||||
|
- Running totals shown below table: Taxable, GST, Grand Total
|
||||||
|
|
||||||
|
**Section 3 — Terms & Conditions**
|
||||||
|
- Delivery, Dispatch, Inspection, Transit Insurance, Payment Terms, Others (all text, optional)
|
||||||
|
|
||||||
|
**Section 4 — Documents**
|
||||||
|
- Drag-and-drop or browse file uploader
|
||||||
|
- Shows list of attached files
|
||||||
|
|
||||||
|
**Footer actions**
|
||||||
|
- Save as Draft
|
||||||
|
- Submit for Approval
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.7 Edit PO `/po/[id]/edit`
|
||||||
|
|
||||||
|
Identical form to New PO, pre-filled with existing data.
|
||||||
|
|
||||||
|
Available only when status = DRAFT or EDITS_REQUESTED, and the user is the submitter or SUPERUSER.
|
||||||
|
|
||||||
|
Footer actions:
|
||||||
|
- Save as Draft
|
||||||
|
- Update & Resubmit (only shown when status = EDITS_REQUESTED; transitions back to MGR_REVIEW)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.8 Import PO `/po/import`
|
||||||
|
|
||||||
|
Upload an Excel file in Pelagia's standard PO template format.
|
||||||
|
|
||||||
|
Steps (wizard-style or single page):
|
||||||
|
1. Drop / upload .xlsx file
|
||||||
|
2. System parses line items, vendor, quotation details
|
||||||
|
3. User selects Vessel and Account (not parsed from file)
|
||||||
|
4. Preview of extracted line items in editable table
|
||||||
|
5. Save as Draft
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.9 Confirm Receipt `/po/[id]/receipt`
|
||||||
|
|
||||||
|
Receipt confirmation form. Shown only when status = PAID_DELIVERED.
|
||||||
|
|
||||||
|
- PO number and title shown as context
|
||||||
|
- File upload for delivery receipt document
|
||||||
|
- Optional notes field
|
||||||
|
- Submit button → transitions PAID_DELIVERED → CLOSED
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.10 Payment Queue `/payments`
|
||||||
|
|
||||||
|
ACCOUNTS role only.
|
||||||
|
|
||||||
|
Card list of POs in MGR_APPROVED and SENT_FOR_PAYMENT statuses.
|
||||||
|
|
||||||
|
**Per card**
|
||||||
|
- PO number, title
|
||||||
|
- Vessel, Submitter, Vendor
|
||||||
|
- Approved date
|
||||||
|
- Amount (prominent)
|
||||||
|
- Status badge: "Ready for Payment" or "Processing — awaiting confirmation"
|
||||||
|
|
||||||
|
**Per card actions**
|
||||||
|
- MGR_APPROVED → "Send for Payment" button
|
||||||
|
- SENT_FOR_PAYMENT → "Mark as Paid" button
|
||||||
|
- View PO detail link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.11 History & Export `/history`
|
||||||
|
|
||||||
|
All POs in all statuses. MANAGER, SUPERUSER, ACCOUNTS, AUDITOR, ADMIN.
|
||||||
|
|
||||||
|
**Filter bar**
|
||||||
|
- Date range (from / to)
|
||||||
|
- Vessel dropdown
|
||||||
|
- Status dropdown
|
||||||
|
|
||||||
|
**Table columns**: PO Number, Title, Vessel, Submitter, Status badge, Amount, Created date
|
||||||
|
|
||||||
|
**Export buttons** (apply current filters to export)
|
||||||
|
- Export PDF
|
||||||
|
- Export CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.12 Vendor Registry `/admin/vendors`
|
||||||
|
|
||||||
|
Vendor list. MANAGER, ACCOUNTS, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Vendor ID (or "Pending"), Name, Contact (name + email), Item count, Verified badge, Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add Vendor button → modal form (GSTIN lookup, name, address, pincode auto-filled via GST portal captcha; manual contact fields)
|
||||||
|
- Edit / Delete per row
|
||||||
|
- Click vendor name → Vendor Detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.13 Vendor Detail `/admin/vendors/[id]`
|
||||||
|
|
||||||
|
**Header**
|
||||||
|
- Vendor name, vendor ID, verified / active badges
|
||||||
|
- Edit button
|
||||||
|
|
||||||
|
**Info card**
|
||||||
|
- GSTIN, address, pincode, contact name, mobile, email
|
||||||
|
|
||||||
|
**Items supplied table**
|
||||||
|
- Product code, name, last quoted price, last updated
|
||||||
|
- Click product name → Item Detail page
|
||||||
|
|
||||||
|
**Recent POs table**
|
||||||
|
- PO number, status, amount, created date (last 10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.14 GSTIN Lookup (modal / inline within vendor form)
|
||||||
|
|
||||||
|
Two-step flow embedded in the Add / Edit Vendor form:
|
||||||
|
|
||||||
|
1. User types a 15-character GSTIN and clicks "Look up"
|
||||||
|
2. System loads GST portal captcha image from the microservice → displays inline
|
||||||
|
3. User types the 6-digit captcha answer
|
||||||
|
4. User clicks "Verify" → microservice submits to GST portal → returns taxpayer data
|
||||||
|
5. Form auto-fills: name, address, pincode (lat/lng geocoded silently from pincode)
|
||||||
|
|
||||||
|
Error states: wrong captcha (shows error, resets), session expired (auto-reset), GST portal unavailable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.15 Item Catalogue `/admin/products`
|
||||||
|
|
||||||
|
MANAGER, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Name, Code, Description, Vendor count, Last price, Last vendor, Updated date, Status badge
|
||||||
|
|
||||||
|
Footer note: "Items are added automatically when a PO is marked as paid."
|
||||||
|
|
||||||
|
**Actions** (ADMIN only)
|
||||||
|
- Add Product → modal form (code, name, description)
|
||||||
|
- Toggle Active / Inactive per row
|
||||||
|
- Delete per row
|
||||||
|
- Click name → Item Detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.16 Item Detail `/admin/products/[id]`
|
||||||
|
|
||||||
|
**Header**
|
||||||
|
- Name, code, status badge, description
|
||||||
|
- Add to Cart button
|
||||||
|
- Toggle Active button (ADMIN only)
|
||||||
|
|
||||||
|
**Stat cards**
|
||||||
|
- Vendor count, Lowest price, Highest price, Sites with stock
|
||||||
|
|
||||||
|
**Price comparison bar chart**
|
||||||
|
- One bar per vendor, Y-axis = unit price
|
||||||
|
|
||||||
|
**Site distance filter**
|
||||||
|
- Dropdown: "Sort by distance from site" — re-sorts vendor table by proximity
|
||||||
|
- Uses geocoded pincode of vendor vs site lat/lng for distance
|
||||||
|
|
||||||
|
**Vendor pricing table**
|
||||||
|
- Columns: Vendor (link to vendor detail), Verified badge, Unit price, Distance (if site selected), Last updated, Add to Cart
|
||||||
|
- Closest vendor gets a ★ marker when a site is selected
|
||||||
|
|
||||||
|
**Stock by site**
|
||||||
|
- Chip list: site name + quantity on hand (link to site detail)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.17 Vessel Management `/admin/vessels`
|
||||||
|
|
||||||
|
MANAGER, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Name, IMO Number, Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add / Edit / Delete per row (all modal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.18 Account / Cost Centre Management `/admin/accounts`
|
||||||
|
|
||||||
|
MANAGER, ADMIN.
|
||||||
|
|
||||||
|
**Table columns**: Code, Name, Description, Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add / Edit / Delete per row (all modal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.19 Sites `/admin/sites`
|
||||||
|
|
||||||
|
MANAGER, ADMIN (ADMIN-only for add/edit/delete).
|
||||||
|
|
||||||
|
Ports, depots, and offices that hold inventory.
|
||||||
|
|
||||||
|
**Table columns**: Name, Code, Address, Vessels, Items tracked, Location (lat/lon from pincode), Status badge
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add Site → modal form (name, code, address, pincode for auto-geocoding)
|
||||||
|
- Edit / Delete per row
|
||||||
|
- Click name → Site Detail page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.20 Site Detail `/admin/sites/[id]`
|
||||||
|
|
||||||
|
**Header**
|
||||||
|
- Name, code, address, geocoded location
|
||||||
|
- Edit button (ADMIN only)
|
||||||
|
|
||||||
|
**Stat cards**
|
||||||
|
- Vessels at site, Items tracked, Total inventory value (if calculable)
|
||||||
|
|
||||||
|
**Inventory bar chart**
|
||||||
|
- X-axis = product name, Y-axis = quantity on hand
|
||||||
|
|
||||||
|
**Consumption line chart**
|
||||||
|
- Last 30 days of daily consumption, one line per product
|
||||||
|
|
||||||
|
**Inventory table**
|
||||||
|
- Product name, quantity on hand, last updated; link to item detail
|
||||||
|
|
||||||
|
**Log consumption form**
|
||||||
|
- Fields: Product (dropdown), Date (date picker), Quantity, Note
|
||||||
|
- Submits immediately; chart and table refresh
|
||||||
|
|
||||||
|
**Assigned vessels**
|
||||||
|
- Chip list linking to vessel detail
|
||||||
|
|
||||||
|
**Recent POs for this site**
|
||||||
|
- Last 8 POs with status, vendor, amount
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.21 User Management `/admin/users`
|
||||||
|
|
||||||
|
ADMIN only.
|
||||||
|
|
||||||
|
**Table columns**: Employee ID, Name, Email, Role badge, Status badge, Created date
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Add User → modal form (employee ID, name, email, role, initial password)
|
||||||
|
- Edit → modal form (same fields, password optional)
|
||||||
|
- Delete per row
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5.22 Cart `/inventory/cart`
|
||||||
|
|
||||||
|
Persistent cart collecting items selected from product detail pages. Stored in localStorage.
|
||||||
|
|
||||||
|
**Cart view**
|
||||||
|
- Item list: product name, description, vendor (if selected), unit price, quantity (editable inline)
|
||||||
|
- Summary: subtotal, GST, grand total
|
||||||
|
- Site selector (to indicate delivery site)
|
||||||
|
|
||||||
|
**Actions**
|
||||||
|
- Remove item
|
||||||
|
- Clear cart
|
||||||
|
- Create PO → opens New PO form pre-filled with cart line items and selected site/vendor
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. PO Lifecycle State Machine
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────┐
|
||||||
|
▼ │
|
||||||
|
[DRAFT] ──submit──► [SUBMITTED] ──auto──► [MGR_REVIEW]
|
||||||
|
│ │ │ │
|
||||||
|
approve ◄───────┘ │ │ └──── reject ──► [REJECTED]
|
||||||
|
│ │ │
|
||||||
|
│ request_edits─┘ └── request_vendor_id ──► [VENDOR_ID_PENDING]
|
||||||
|
│ │
|
||||||
|
│ ◄──── provide_vendor_id ──────────────────────┘
|
||||||
|
│
|
||||||
|
[MGR_APPROVED]
|
||||||
|
│
|
||||||
|
process_payment
|
||||||
|
│
|
||||||
|
[SENT_FOR_PAYMENT]
|
||||||
|
│
|
||||||
|
mark_paid
|
||||||
|
│
|
||||||
|
[PAID_DELIVERED]
|
||||||
|
│
|
||||||
|
confirm_receipt
|
||||||
|
│
|
||||||
|
[CLOSED]
|
||||||
|
```
|
||||||
|
|
||||||
|
States that allow re-entry into the flow:
|
||||||
|
- **EDITS_REQUESTED** → submitter edits PO → re-submits → MGR_REVIEW
|
||||||
|
- **VENDOR_ID_PENDING** → submitter selects vendor → MGR_REVIEW
|
||||||
|
|
||||||
|
Terminal states: **REJECTED**, **CLOSED**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Workflows
|
||||||
|
|
||||||
|
### 7.1 Submit a Purchase Order (TECHNICAL / MANNING)
|
||||||
|
|
||||||
|
1. Click **New PO** in sidebar
|
||||||
|
2. Select vessel and account
|
||||||
|
3. Add line items (type name to search item catalogue; previous vendor prices appear as hints)
|
||||||
|
4. Optionally attach documents and fill in T&C fields
|
||||||
|
5. Click **Submit for Approval**
|
||||||
|
6. Manager receives email notification
|
||||||
|
7. Status shows as "Under Review" on My Orders page
|
||||||
|
8. If manager requests edits: submitter sees EDITS_REQUESTED status with manager note; edits form; resubmits
|
||||||
|
9. If manager requests vendor ID: submitter selects a vendor and submits; returns to manager queue
|
||||||
|
10. On approval: submitter notified by email; accounts team can see PO in payment queue
|
||||||
|
|
||||||
|
### 7.2 Approve a Purchase Order (MANAGER)
|
||||||
|
|
||||||
|
1. Click **Approvals** in sidebar; see count of pending POs
|
||||||
|
2. Click **Review** on a PO
|
||||||
|
3. Read full detail: line items, vendor, documents, submitter notes
|
||||||
|
4. Optionally: click **Edit** to adjust line items, change vendor, vessel, or account
|
||||||
|
5. Choose action:
|
||||||
|
- **Approve** → immediately moves to accounts payment queue
|
||||||
|
- **Approve with Note** → same, with a note visible to submitter
|
||||||
|
- **Request Edits** → write note explaining required changes; PO returned to submitter
|
||||||
|
- **Request Vendor ID** → PO returned to submitter to select vendor; then returns to manager queue
|
||||||
|
- **Reject** → write reason; PO is closed permanently
|
||||||
|
|
||||||
|
### 7.3 Process a Payment (ACCOUNTS)
|
||||||
|
|
||||||
|
1. Click **Payments** in sidebar
|
||||||
|
2. See cards for all MGR_APPROVED POs
|
||||||
|
3. Click **Send for Payment** → initiates payment; notifies submitter and manager
|
||||||
|
4. When payment is confirmed by bank/finance: click **Mark as Paid** → notifies all parties
|
||||||
|
5. Submitter can now upload delivery receipt
|
||||||
|
|
||||||
|
### 7.4 Confirm Receipt (TECHNICAL / MANNING)
|
||||||
|
|
||||||
|
1. Goods are delivered on site / to vessel
|
||||||
|
2. Navigate to PO detail page (status = PAID_DELIVERED)
|
||||||
|
3. Click **Confirm Receipt**
|
||||||
|
4. Upload delivery receipt document and optionally add notes
|
||||||
|
5. Submit → PO is CLOSED; accounts and manager notified
|
||||||
|
|
||||||
|
### 7.5 Look Up a Vendor by GSTIN (MANAGER / ADMIN)
|
||||||
|
|
||||||
|
1. Open Add/Edit Vendor modal
|
||||||
|
2. Type the 15-digit GSTIN
|
||||||
|
3. Click **Look up** → captcha image loads from GST portal (via microservice)
|
||||||
|
4. Type the 6-digit captcha shown in the image
|
||||||
|
5. Click **Verify** → form auto-fills with legal name, trade name, registered address, pincode
|
||||||
|
6. Review and save; location is geocoded silently from pincode for distance calculations
|
||||||
|
|
||||||
|
### 7.6 Source Items by Proximity (MANAGER)
|
||||||
|
|
||||||
|
1. Navigate to **Items** → click an item name
|
||||||
|
2. See all vendors that supply the item with their last quoted price
|
||||||
|
3. Select a **site** from the "Sort by distance from" dropdown
|
||||||
|
4. Table re-sorts: vendors nearest to the site appear first; distance shown per row; closest vendor marked ★
|
||||||
|
5. Click **Add to Cart** on the desired vendor row → item added to cart
|
||||||
|
|
||||||
|
### 7.7 Create a PO from the Cart (MANAGER / TECHNICAL)
|
||||||
|
|
||||||
|
1. Browse Item catalogue and add items to cart (Add to Cart button per vendor row)
|
||||||
|
2. Click **Cart** in sidebar
|
||||||
|
3. Review cart: adjust quantities inline; remove items; select delivery site
|
||||||
|
4. Click **Create PO** → opens New PO form pre-filled with all cart items and vendor
|
||||||
|
5. Fill in title, vessel, account; submit normally
|
||||||
|
|
||||||
|
### 7.8 Track Inventory at a Site (MANAGER / ADMIN)
|
||||||
|
|
||||||
|
1. Navigate to **Sites** → click a site
|
||||||
|
2. View bar chart of current stock (quantity per product)
|
||||||
|
3. View consumption line chart (last 30 days)
|
||||||
|
4. Use **Log Consumption** form to record daily drawdown: select product, pick date, enter quantity
|
||||||
|
|
||||||
|
### 7.9 Auto-sync Catalogue on Payment Confirmation (ACCOUNTS → SYSTEM)
|
||||||
|
|
||||||
|
When accounts clicks **Mark as Paid**:
|
||||||
|
- System checks each PO line item that has a product link
|
||||||
|
- For unlinked items: attempts fuzzy-match on name; creates new product record if no match
|
||||||
|
- Upserts `ProductVendorPrice` — if this vendor/product combination is new or the price changed, updates the catalogue
|
||||||
|
- Sets `Product.lastPrice` and `Product.lastVendorId`
|
||||||
|
- Future POs using that product name will see this vendor's latest price as a hint
|
||||||
|
|
||||||
|
### 7.10 Import a PO from Excel (MANAGER)
|
||||||
|
|
||||||
|
1. Navigate to **Import PO**
|
||||||
|
2. Upload an Excel file in Pelagia's standard template format
|
||||||
|
3. System extracts: line items (name, description, qty, unit, price, GST), vendor details, quotation number/date
|
||||||
|
4. User selects vessel and account from dropdowns
|
||||||
|
5. Review and optionally edit extracted line items
|
||||||
|
6. Save as Draft → PO created; submitter can then edit and submit
|
||||||
|
|
||||||
|
### 7.11 Export PO History (AUDITOR / MANAGER)
|
||||||
|
|
||||||
|
1. Navigate to **History**
|
||||||
|
2. Apply filters: date range, vessel, status
|
||||||
|
3. Click **Export PDF** or **Export CSV**
|
||||||
|
4. File downloaded with all matching POs; up to 200 results per export
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Data Entities
|
||||||
|
|
||||||
|
### Purchase Order
|
||||||
|
Fields: PO number (auto-generated), title, status, total amount, currency, date required, project code, manager note, payment reference, quotation number/date, requisition number/date, place of delivery, all T&C text fields, timestamps.
|
||||||
|
|
||||||
|
### PO Line Item
|
||||||
|
Fields: name, description, quantity, unit, size, unit price, GST rate (default 18%), total price (computed), sort order, optional product link.
|
||||||
|
|
||||||
|
### Vendor
|
||||||
|
Fields: name, vendor ID (optional, unique), address, pincode, GSTIN, contact name/mobile/email, latitude/longitude (geocoded silently from pincode), verified flag, active flag.
|
||||||
|
|
||||||
|
### Product (Item)
|
||||||
|
Fields: code (auto-generated or manual), name, description, last price, last vendor, active flag. Prices tracked per vendor via `ProductVendorPrice` (one record per product–vendor pair).
|
||||||
|
|
||||||
|
### Vessel
|
||||||
|
Fields: name, IMO number (optional), active flag, assigned site (optional).
|
||||||
|
|
||||||
|
### Site
|
||||||
|
Fields: name, code, address, pincode, latitude/longitude, active flag.
|
||||||
|
|
||||||
|
### Account (Cost Centre)
|
||||||
|
Fields: code, name, description, active flag.
|
||||||
|
|
||||||
|
### User
|
||||||
|
Fields: employee ID, email, name, role, active flag, password hash.
|
||||||
|
|
||||||
|
### Inventory & Consumption
|
||||||
|
- `ItemInventory`: quantity of a product at a site (one row per product–site pair)
|
||||||
|
- `ItemConsumption`: daily draw-down record (one row per product–site–date)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Key UI Patterns
|
||||||
|
|
||||||
|
### Status Badges
|
||||||
|
Each PO status has a distinct colour:
|
||||||
|
- DRAFT — neutral grey
|
||||||
|
- SUBMITTED / MGR_REVIEW — blue (in-progress)
|
||||||
|
- VENDOR_ID_PENDING — orange/warning
|
||||||
|
- EDITS_REQUESTED — yellow/warning
|
||||||
|
- MGR_APPROVED — teal/success-adjacent
|
||||||
|
- SENT_FOR_PAYMENT — purple
|
||||||
|
- PAID_DELIVERED — blue-green
|
||||||
|
- CLOSED — green/success
|
||||||
|
- REJECTED — red/danger
|
||||||
|
|
||||||
|
### Confirmation before Destructive Actions
|
||||||
|
Delete buttons use a two-step inline confirm: "Delete [name]? Confirm / Cancel". No modal dialog — the confirm state replaces the button in-place.
|
||||||
|
|
||||||
|
### Inline Editing in Tables
|
||||||
|
Manager line-item editing in the approval flow happens in an inline form on the same page, not in a modal, so the manager can reference the rest of the PO while editing.
|
||||||
|
|
||||||
|
### GST Calculation (always visible in PO forms)
|
||||||
|
Below the line-items table, a live summary shows:
|
||||||
|
- Taxable amount (sum of qty × unit price)
|
||||||
|
- GST amount (sum of qty × unit price × GST rate)
|
||||||
|
- Grand Total (taxable + GST)
|
||||||
|
|
||||||
|
### Product Autocomplete
|
||||||
|
In the PO line-item name field, typing triggers a fuzzy search of the item catalogue. Dropdown shows:
|
||||||
|
- Product name and code
|
||||||
|
- Price hints per vendor: "Vendor A: ₹1,200 · Vendor B: ₹1,050"
|
||||||
|
|
||||||
|
### Cart Persistence
|
||||||
|
Cart is stored in browser `localStorage` under a fixed key. It survives navigation but is local to the device and user. A `cart-updated` custom event allows components to react to changes in real time.
|
||||||
|
|
||||||
|
### Notifications / Emails
|
||||||
|
Every PO status transition triggers an email to relevant parties:
|
||||||
|
- Submit → manager
|
||||||
|
- Approve → submitter + accounts
|
||||||
|
- Reject → submitter
|
||||||
|
- Request Edits → submitter
|
||||||
|
- Request Vendor ID → submitter
|
||||||
|
- Payment sent → submitter + manager
|
||||||
|
- Mark paid → submitter + manager
|
||||||
|
- Receipt confirmed → manager + accounts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Non-Goals (Out of Scope)
|
||||||
|
|
||||||
|
- Mobile app (web-only, desktop-first)
|
||||||
|
- Public-facing pages (entirely internal)
|
||||||
|
- Self-registration / OAuth login
|
||||||
|
- Vendor portal (vendors do not log in)
|
||||||
|
- Automated bank/payment-gateway integration (payment is marked manually)
|
||||||
29
design_handoff_pelagia_portal/Pelagia Portal.html
Normal file
29
design_handoff_pelagia_portal/Pelagia Portal.html
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=1280" />
|
||||||
|
<title>Pelagia Portal</title>
|
||||||
|
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
|
||||||
|
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
<script type="text/babel" src="components.jsx"></script>
|
||||||
|
<script type="text/babel" src="data.jsx"></script>
|
||||||
|
<script type="text/babel" src="pages-1.jsx"></script>
|
||||||
|
<script type="text/babel" src="pages-2.jsx"></script>
|
||||||
|
<script type="text/babel" src="pages-3.jsx"></script>
|
||||||
|
<script type="text/babel" src="pages-4.jsx"></script>
|
||||||
|
<script type="text/babel" src="app.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
398
design_handoff_pelagia_portal/README.md
Normal file
398
design_handoff_pelagia_portal/README.md
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
# Handoff: Pelagia Portal — Internal Purchase-Order Management
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Pelagia Portal is an internal web application for a maritime company that digitises the full purchase-order lifecycle: requisition aboard a vessel → manager approval → finance payment → receipt confirmation on delivery. It is a role-gated, traceable workflow replacing paper and email-based processes.
|
||||||
|
|
||||||
|
This handoff covers the design system and 16 primary screens spanning the seven user roles defined in the spec (TECHNICAL, MANNING, ACCOUNTS, MANAGER, SUPERUSER, AUDITOR, ADMIN).
|
||||||
|
|
||||||
|
The full functional specification lives in `DESIGN.md` (included). The HTML prototype demonstrates layout, look, and key interactions; this README documents what to build.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## About the Design Files
|
||||||
|
|
||||||
|
The files in this bundle are **design references created in HTML/JSX** — prototypes showing intended look and behaviour, **not production code to ship**. They use React via Babel-in-browser for fast iteration, with no build step, no API layer, and mock data hard-coded in `data.jsx`.
|
||||||
|
|
||||||
|
The task is to **recreate these designs in the target codebase's existing environment** (likely Next.js / React + a real backend) using its established patterns, component library, and conventions. If no environment exists yet, pick the most appropriate stack — Next.js App Router with TypeScript + Tailwind/shadcn or Radix is a natural fit for this product.
|
||||||
|
|
||||||
|
A role-switcher dropdown appears in the prototype header. **It is a prototype-only affordance** to preview how the sidebar adapts per role. In production, role is determined by the authenticated user's record.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fidelity
|
||||||
|
|
||||||
|
**High-fidelity.** Final typography, colour, spacing, status-badge palette, table density, and form layout are intended to ship as-is. The chrome (header, sidebar, page-head, card, table, badge) is a complete design system — recreate it pixel-faithfully with the codebase's chosen primitives.
|
||||||
|
|
||||||
|
Charts are illustrative — the line and bar charts in the prototype are hand-rolled CSS/SVG. In production use a charting library (Recharts, Visx, Chart.js) but keep the visual style: low chroma fills, monospaced numerals, minimal axes, no gradients.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design Tokens
|
||||||
|
|
||||||
|
### Colour
|
||||||
|
|
||||||
|
All colours are defined in `oklch` and pinned to a consistent chroma/lightness band per role (foreground vs background, status hue, etc). Convert to hex/RGB for the target environment if needed.
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Surface */
|
||||||
|
--paper: oklch(98% 0.005 240); /* page background — warm-cool off-white */
|
||||||
|
--paper-2: oklch(96.5% 0.006 240); /* recessed surfaces, table headers */
|
||||||
|
--surface: #ffffff; /* cards, inputs */
|
||||||
|
|
||||||
|
/* Ink */
|
||||||
|
--ink: oklch(22% 0.02 245); /* primary text */
|
||||||
|
--ink-2: oklch(35% 0.02 245); /* secondary text */
|
||||||
|
--muted: oklch(55% 0.015 245); /* labels, meta */
|
||||||
|
--faint: oklch(72% 0.012 245); /* placeholder, separator copy */
|
||||||
|
|
||||||
|
/* Lines */
|
||||||
|
--line: oklch(91% 0.006 245); /* default borders */
|
||||||
|
--line-2: oklch(87% 0.008 245); /* hover/active borders */
|
||||||
|
|
||||||
|
/* Primary maritime */
|
||||||
|
--primary: oklch(38% 0.06 230); /* brand mark, primary CTA */
|
||||||
|
--primary-ink: oklch(28% 0.06 230); /* primary CTA hover, active nav text */
|
||||||
|
--primary-soft: oklch(95% 0.02 230); /* active nav background, soft fills */
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Badge Palette
|
||||||
|
|
||||||
|
Nine PO statuses, each with a paired bg/fg keeping chroma and lightness consistent across the set:
|
||||||
|
|
||||||
|
| Status | Background hue | Foreground hue |
|
||||||
|
|----------------------|----------------------|------------------------|
|
||||||
|
| DRAFT | `oklch(94% 0.005 245)` | `oklch(40% 0.015 245)` |
|
||||||
|
| SUBMITTED / MGR_REVIEW | `oklch(94% 0.03 245)` | `oklch(38% 0.09 245)` |
|
||||||
|
| VENDOR_ID_PENDING | `oklch(94% 0.04 60)` | `oklch(42% 0.12 60)` |
|
||||||
|
| EDITS_REQUESTED | `oklch(95% 0.05 95)` | `oklch(42% 0.1 80)` |
|
||||||
|
| MGR_APPROVED | `oklch(94% 0.04 190)` | `oklch(38% 0.08 195)` |
|
||||||
|
| SENT_FOR_PAYMENT | `oklch(94% 0.04 300)` | `oklch(40% 0.1 300)` |
|
||||||
|
| PAID_DELIVERED | `oklch(94% 0.04 215)` | `oklch(38% 0.08 220)` |
|
||||||
|
| CLOSED | `oklch(94% 0.04 150)` | `oklch(38% 0.08 150)` |
|
||||||
|
| REJECTED | `oklch(94% 0.04 25)` | `oklch(45% 0.13 28)` |
|
||||||
|
|
||||||
|
Badge style: pill (100px radius), 2×8 padding, 10.5px uppercase, 5px round dot prefix using `currentColor`.
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
- **UI sans:** Geist, 400 / 500 / 600 / 700 (Google Fonts).
|
||||||
|
- **Mono:** Geist Mono, 400 / 500 / 600 — used for PO numbers, amounts, codes, timestamps, GSTINs, geocoded coordinates. Always with `font-variant-numeric: tabular-nums`.
|
||||||
|
- Base body size **13px** with line-height 1.5.
|
||||||
|
- Page titles 22px / 600 / -0.01em tracking.
|
||||||
|
- Section titles 11px uppercase / 500 / 0.08em tracking, colour `--muted`.
|
||||||
|
- Field labels 11px uppercase / 500 / 0.05em.
|
||||||
|
- Stat values 26px monospaced / 500 / -0.02em.
|
||||||
|
|
||||||
|
### Spacing & Radii
|
||||||
|
|
||||||
|
- Sidebar width **232px**, header height **52px**, main padding `22px 32px 60px`, max content width **1280px**.
|
||||||
|
- Border radii: `4px` (chips), `6px` (buttons, inputs), `10px` (cards, large surfaces).
|
||||||
|
- Form input height **32px**, secondary/button height **30px**, small-button **26px**.
|
||||||
|
- Card head padding `13px 16px`, body `16px`. Tables: header `10px 16px`, cell `12px 16px`.
|
||||||
|
- Grid gap **12–16px** for stat grids / dashboard layouts; **22px** between page sections.
|
||||||
|
|
||||||
|
### Iconography
|
||||||
|
|
||||||
|
Custom 16×16 monoline SVG icons with `stroke-width: 1.4`, `linecap: round`, `linejoin: round`. Recreate in your icon library of choice (Lucide is a near-perfect match — `Home`, `FileText`, `List`, `Check`, `ShoppingCart`, `Package`, `Truck`, `Users`, `Ship`, `Map`, `BarChart3`, `User`, `Settings`, `Plus`, `Download`, `Upload`, `Search`, `ChevronRight`, `Star`, `Pencil`, `Trash`, `Bell`, `Eye`, `RotateCw`, `Paperclip`, `ArrowRight`).
|
||||||
|
|
||||||
|
### Shadow / Elevation
|
||||||
|
|
||||||
|
**No drop shadows** anywhere in the chrome. Depth comes from hairline borders (`--line`) and the recessed `--paper-2` background. The brand mark sits on solid `--primary`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Navigation Structure
|
||||||
|
|
||||||
|
The left sidebar is role-aware (groups and items hide/show per role). Group titles are 10.5px uppercase, `--faint`.
|
||||||
|
|
||||||
|
| Role | Groups visible |
|
||||||
|
|---------------|-------------------------------------------------------|
|
||||||
|
| TECHNICAL / MANNING | Dashboard · Purchase Orders (New PO, My Orders) · Inventory (Items, Cart) |
|
||||||
|
| MANAGER | Dashboard · Purchase Orders (New PO, My Orders, Approvals, Import PO, History) · Inventory (Vendors, Items, Vessels, Sites, Cart) |
|
||||||
|
| ACCOUNTS | Dashboard · Purchase Orders (Payments, History) · Inventory (Vendors) |
|
||||||
|
| SUPERUSER | Full submitter + manager + accounts surface |
|
||||||
|
| AUDITOR | Dashboard · Purchase Orders (History) |
|
||||||
|
| ADMIN | Dashboard · Purchase Orders (Import PO, History) · Inventory (Vendors, Items, Vessels, Sites) · Administration (Users, Accounts) |
|
||||||
|
|
||||||
|
Nav item: 6×10 padding, 6px radius, 12.5px label, leading 14px icon. **Active state**: `--primary-soft` background, `--primary-ink` text, weight 500. Optional count pill on the right (10.5px monospace).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Screen Catalogue
|
||||||
|
|
||||||
|
### 1. Login (`/login`)
|
||||||
|
|
||||||
|
- Centred 360px-wide card on `--paper` background.
|
||||||
|
- Brand mark + "Pelagia Portal" wordmark + 11.5px subtitle "Internal · Purchase Order Management".
|
||||||
|
- Email + password inputs, then a full-width maritime-primary "Sign in" button (height 36).
|
||||||
|
- Footnote: "Contact an administrator to request access." No self-registration.
|
||||||
|
|
||||||
|
### 2. Dashboard (`/dashboard`)
|
||||||
|
|
||||||
|
Content adapts per role. Page-head left: crumb "Dashboard", title "Good afternoon, <name>", sub-line with date/time and a "3 vessels active at sea" indicator. Page-head right: Export + New PO buttons.
|
||||||
|
|
||||||
|
**Manager view:**
|
||||||
|
- 4 stat tiles in `auto-fit minmax(190px, 1fr)` grid: Awaiting approval (clickable → approvals queue, shows trend "↑ 2 vs last week"), Approved this month, Approved spend, Avg cycle (submit → approve).
|
||||||
|
- Two side-by-side cards (1fr 1fr): "Monthly spend — last 8 months" (vertical bar chart, ₹ in thousands, current month highlighted in `--primary`); "Spend by vessel — YTD" (horizontal bar rows with `--primary` fills).
|
||||||
|
- "Recently approved" card-flush table with "View history →" link.
|
||||||
|
|
||||||
|
**Submitter (TECH/MANNING) view:**
|
||||||
|
- 4 stat tiles: Open orders, Pending approval, Completed, Total spend YTD.
|
||||||
|
- "Open orders" mini-table.
|
||||||
|
|
||||||
|
**Accounts view:**
|
||||||
|
- 4 stat tiles: Ready for payment, Awaiting confirmation, Value awaiting payment, Paid this month.
|
||||||
|
- "Payments queue" link.
|
||||||
|
|
||||||
|
**Auditor/Admin view:**
|
||||||
|
- 4 stat tiles + quick-access button row (Order history, Vendor registry, Export PDF, Export CSV).
|
||||||
|
|
||||||
|
### 3. My Purchase Orders (`/my-orders`)
|
||||||
|
|
||||||
|
- Page-head with status counts: `<n> open · <n> past`.
|
||||||
|
- Two stacked sections: "Open orders" and "Past orders", each a flush card with table.
|
||||||
|
- Columns: PO Number (monospace, `--primary-ink`), Title, Vessel (muted), Status badge, Updated, Amount (right-aligned monospace).
|
||||||
|
- For EDITS_REQUESTED rows, a tinted sub-row below the data row shows `Manager note · <name>: <text>` with the inline badge.
|
||||||
|
- Whole row clickable → `/po/[id]`. Hover row: `--paper-2`.
|
||||||
|
|
||||||
|
### 4. Approval Queue (`/approvals`)
|
||||||
|
|
||||||
|
- Title "Approval queue" + appended count "· N pending" in muted regular weight.
|
||||||
|
- Filter bar (recessed `--paper-2`, 1px border, 6px radius): search field with icon, vessel dropdown, submitter dropdown, date-from picker, right-aligned "Sorted by submitted date" note.
|
||||||
|
- Table columns: PO Number, Title, Submitter, Vessel, Submitted, Amount, "Review →" link on right.
|
||||||
|
|
||||||
|
### 5. PO Detail (`/po/[id]`)
|
||||||
|
|
||||||
|
Two-column layout: main content (1fr) + sidebar (280px).
|
||||||
|
|
||||||
|
- **Detail band** (full width, above the two-col): card with PO id (18px monospace), status badge, project code chip (mono pill), and right-aligned "GRAND TOTAL · ₹X,XX,XXX" (22px monospace).
|
||||||
|
- Manager-note alert if `EDITS_REQUESTED` (yellow-warning alert above main content).
|
||||||
|
- **Main column** sections (each preceded by an 11px uppercase section title):
|
||||||
|
- Summary — key/value `dl` (`130px 1fr` columns).
|
||||||
|
- Line items — flush card with table; footer row inside card has Taxable / GST / Grand Total summary (right-aligned, monospace).
|
||||||
|
- Terms & conditions — key/value `dl` of the 6 T&C fields.
|
||||||
|
- Documents — list of attached files with `paperclip` icon + size + Download button.
|
||||||
|
- Confirm receipt — visible only when `PAID_DELIVERED`: dashed-border upload zone + notes textarea + maritime CTA.
|
||||||
|
- **Sidebar:**
|
||||||
|
- Timestamps card — Created / Submitted / Approved / Paid / Closed (monospace dates).
|
||||||
|
- Audit trail card — list of `actor · action · monospaced timestamp` rows with green dot per row (1px dashed separator between).
|
||||||
|
|
||||||
|
Action buttons in page-head are contextual per status & role (see DESIGN.md §5.4 table).
|
||||||
|
|
||||||
|
### 6. Approval Detail (`/approvals/[id]`)
|
||||||
|
|
||||||
|
Same as PO Detail, plus a **Manager actions** card at the top of the main column:
|
||||||
|
|
||||||
|
- Default state: row of buttons — `Approve` (maritime primary), `Approve with Note`, `Request Edits`, `Request Vendor ID`, with `Reject` (danger) pushed right via a flex spacer.
|
||||||
|
- Clicking any action other than plain Approve swaps the row for an inline form with title, subtitle, textarea, Cancel + tinted-CTA buttons. CTA colour matches the action's intent (edits = `--st-edits-fg`, reject = `--st-rejected-fg`, etc).
|
||||||
|
- Manager line-item editing happens inline on the line-items card (not in a modal) — per spec §9.
|
||||||
|
|
||||||
|
### 7. New PO (`/po/new`)
|
||||||
|
|
||||||
|
Four numbered sections, each prefixed by a section title:
|
||||||
|
|
||||||
|
1. **Header** — Title (full row) + 3-col (Vessel, Account, Vendor) + 3-col (Date Required, Project Code, Currency) + Description textarea.
|
||||||
|
2. **Line items** — flush card with table of editable rows. Per-row inputs: Name, Description, Qty, Unit, Unit price, GST%, Total (computed, monospace), trash icon. Footer "Add line item" button + tip text. Card footer with live Taxable / GST / Grand Total summary identical to the PO Detail footer.
|
||||||
|
- As-you-type product autocomplete: when a name input is focused with text, a tinted sub-row appears showing "Last seen at: Vendor A ₹X · Vendor B ₹X · Vendor C ₹X" hints (per spec §9).
|
||||||
|
3. **Terms & conditions** — 6 textareas in a 2-col grid (Delivery, Dispatch, Inspection, Transit Insurance, Payment Terms, Other).
|
||||||
|
4. **Documents** — dashed drop zone with paperclip-list of attached files.
|
||||||
|
|
||||||
|
Footer: Cancel · Save as Draft · Submit for Approval (maritime primary).
|
||||||
|
|
||||||
|
### 8. Payment Queue (`/payments`)
|
||||||
|
|
||||||
|
ACCOUNTS only. Two sections:
|
||||||
|
|
||||||
|
- "Ready for payment" — card grid `auto-fill minmax(320px, 1fr)`.
|
||||||
|
- "Processing — awaiting confirmation" — same grid layout, different action button.
|
||||||
|
|
||||||
|
Each card: PO number (mono header) + title, status badge, kv table (Vessel, Vendor, Submitter, Approved/Sent on), prominent amount (22px monospace), then a row of two buttons — View (neutral) and the action button (maritime primary, larger flex weight 1.6).
|
||||||
|
|
||||||
|
### 9. History / Export (`/history`)
|
||||||
|
|
||||||
|
- Page-head right: Export PDF · Export CSV buttons.
|
||||||
|
- Filter bar: From + To date pickers (with prefix labels), vessel dropdown, status dropdown. Right side: "N matching · export uses current filters".
|
||||||
|
- Full PO table, columns: PO Number, Title, Vessel, Submitter, Status, Created, Amount.
|
||||||
|
|
||||||
|
### 10. Vendor Registry (`/admin/vendors`)
|
||||||
|
|
||||||
|
- Page-head right: "Add vendor" maritime button.
|
||||||
|
- Add-vendor opens an inline panel (not a modal) above the table — a `.action-panel` Card with the **GSTIN lookup wizard**:
|
||||||
|
- Step 1: GSTIN input + "Look up" button.
|
||||||
|
- Step 2: a hand-drawn captcha image placeholder (45° striped background, line-through wavy effect on the 6-char code) + input + Refresh icon button + "Verify" button.
|
||||||
|
- Step 3: green confirmation alert + auto-filled form fields (Legal name, Trade name, Address textarea, Pincode, Geocoded location read-only, Contact name, Email, Mobile) + Cancel / Save.
|
||||||
|
- Vendor table columns: Vendor ID (mono or "Pending" badge), Name + city + GSTIN (two-line cell), Contact (name + email two-line), Items count, Verified (check + "Verified" or em-dash), Status badge, edit icon.
|
||||||
|
|
||||||
|
### 11. Vendor Detail (`/admin/vendors/[id]`)
|
||||||
|
|
||||||
|
Two-column layout (1fr + 280px).
|
||||||
|
- Page-head sub: vendor ID (mono) · "GSTIN verified" check · Active badge.
|
||||||
|
- **Main:** Items supplied table (Code, Product, Last quoted, Last updated) + Recent POs table.
|
||||||
|
- **Sidebar:** Vendor info kv (GSTIN, Pincode, City, Contact, Mobile, Email); Address card with a striped placeholder "map" graphic + geocoded coordinates label.
|
||||||
|
|
||||||
|
### 12. Item Catalogue (`/admin/products`)
|
||||||
|
|
||||||
|
- Info alert at top (per spec §5.15 footer note): "Items are added automatically when a PO is marked as paid. Manual entry is reserved for ADMIN."
|
||||||
|
- Table columns: Code (mono), Name (medium weight), Description (muted), Vendors, Last price, Last vendor, Updated, Status badge.
|
||||||
|
|
||||||
|
### 13. Item Detail (`/admin/products/[id]`)
|
||||||
|
|
||||||
|
- Stat row: Vendors supplying, Lowest price, Highest price, Sites with stock.
|
||||||
|
- 2-col card row: "Price comparison" vertical bar chart (lowest-priced vendor bar in `--primary`) + "Stock by site" chip list.
|
||||||
|
- Vendor pricing table with **site-proximity filter** (top-right "Sort by distance from <Site>" dropdown). When a site is chosen: a Distance column appears, rows sort by distance ascending, and the closest vendor row gets a star ★ marker. Per-row "Add to Cart" small button.
|
||||||
|
|
||||||
|
### 14. Sites (`/admin/sites`) & Site Detail (`/admin/sites/[id]`)
|
||||||
|
|
||||||
|
- List: Name, Code (mono), Address (muted), Vessels, Items, Location (mono coords), Status, edit icon.
|
||||||
|
- Detail: 4 stat tiles (Vessels at site, Items tracked, Inventory value, 30-day consumption). 2-col card row: Current stock horizontal bars + Consumption SVG line chart (5 colored series for 5 products) with a flat legend below. Then a 2/3 + 1/3 split: Inventory table & Recent POs on the left, Log-consumption form & assigned vessels chips on the right.
|
||||||
|
|
||||||
|
### 15. Cart (`/inventory/cart`)
|
||||||
|
|
||||||
|
- 2/3 + 1/3 split.
|
||||||
|
- Left card: header row + per-item rows (5-col grid: name+vendor / price / qty input / subtotal / delete). Inline qty editing.
|
||||||
|
- Right column: "Order summary" card (Taxable / GST / Grand Total) + "Delivery site" dropdown card.
|
||||||
|
- Page-head right: Clear cart · Create PO from cart (maritime primary).
|
||||||
|
|
||||||
|
### 16. Import PO (`/po/import`)
|
||||||
|
|
||||||
|
- Step 1 card (1.6fr): drop zone + parsed-file confirmation row (check icon + "5 line items detected").
|
||||||
|
- Step 2 card (1fr): vessel & account selectors.
|
||||||
|
- Step 3: extracted line-items table (read-only-style) for review.
|
||||||
|
- Footer: Cancel · Save as Draft.
|
||||||
|
|
||||||
|
### 17. Vessels / Accounts / Users (admin tables)
|
||||||
|
|
||||||
|
Straightforward dense tables matching the design system's table primitive. Per-row edit icon, status badge. Add buttons in page-head.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interactions & Behaviour
|
||||||
|
|
||||||
|
### Routing
|
||||||
|
- Hash-based in the prototype (`#dashboard`, `#po-detail/PO-2026-00481`). In production, use real URL routing.
|
||||||
|
- Browser back/forward should restore exact screen state.
|
||||||
|
|
||||||
|
### Click handlers
|
||||||
|
- Every table row with a PO is row-clickable → opens PO Detail. Action buttons within rows must `stopPropagation`.
|
||||||
|
- Status badges are non-clickable (decorative only).
|
||||||
|
- Stat tiles with an underline cursor are clickable to drill into the relevant list.
|
||||||
|
|
||||||
|
### Hover / focus
|
||||||
|
- Buttons: `--paper-2` background and `--line-2` border on hover. Inputs/selects: `--primary` border + 3px `--primary-soft` focus ring.
|
||||||
|
- Nav items: `--paper-2` hover (when not active).
|
||||||
|
- Table rows with `.clickable`: `--paper-2` background.
|
||||||
|
|
||||||
|
### Forms
|
||||||
|
- All required fields marked with a red `*` after the label (`.req`).
|
||||||
|
- Live totals on the New PO line-items table — recompute Taxable / GST / Grand Total on every change.
|
||||||
|
- Save as Draft is always available; Submit requires required fields.
|
||||||
|
|
||||||
|
### Confirmation
|
||||||
|
- Per spec §9: destructive actions use inline two-step confirm (Delete → "Delete <name>? Confirm / Cancel"), **not** a modal.
|
||||||
|
|
||||||
|
### Notifications / emails
|
||||||
|
- Every PO status transition fires an email (spec §9). Outside scope of UI but worth wiring on the backend.
|
||||||
|
|
||||||
|
### Approval action flow
|
||||||
|
- Clicking Approve immediately moves the PO to MGR_APPROVED (toast confirm in production).
|
||||||
|
- Clicking Approve-with-Note / Edits / Reject / Vendor-ID swaps the action panel for an inline form with mandatory note (Edits/Reject) or optional note (Vendor ID, Approve w/ note).
|
||||||
|
|
||||||
|
### Cart persistence
|
||||||
|
- LocalStorage under a fixed key. Fire a `cart-updated` custom event on mutation so header counters can react.
|
||||||
|
|
||||||
|
### GSTIN lookup
|
||||||
|
- Three-step inline wizard inside the Add Vendor form. Captcha image and verify response should be proxied via the company's microservice to the GST portal.
|
||||||
|
|
||||||
|
### Charts
|
||||||
|
- Use Recharts/Visx. Bar charts: rounded top corners, no axis lines, monospaced tick labels in `--muted`, the highlighted bar in `--primary`, the rest in `--primary-soft`. Line charts: 1.5px stroke, rounded caps, 5 distinct hues at moderate chroma (sampled from the consumption chart series).
|
||||||
|
|
||||||
|
### Responsive
|
||||||
|
- Desktop-first per spec §10. Minimum supported width 1280px. The header has fixed-width sidebar (232px); cards collapse to single column under 1024px if you want to graceful-degrade.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
State boundaries that matter:
|
||||||
|
|
||||||
|
- **Auth / current user / role** — global (Context or Zustand). Drives sidebar nav and action visibility.
|
||||||
|
- **PO list / detail** — server state via TanStack Query / RTK Query. Cache invalidation on status transition.
|
||||||
|
- **Approval inline form** — local component state (`mode` enum: null | edits | reject | note | vendor).
|
||||||
|
- **New PO form** — react-hook-form with `useFieldArray` for line items; live totals are derived from `watch`.
|
||||||
|
- **Cart** — `localStorage` + an in-memory store synced via a `cart-updated` event.
|
||||||
|
- **Vendor add wizard** — local component state (`step` 1 → 3); GSTIN captcha and verify are async mutations.
|
||||||
|
|
||||||
|
### PO lifecycle state machine (per spec §6)
|
||||||
|
|
||||||
|
```
|
||||||
|
DRAFT → SUBMITTED → MGR_REVIEW
|
||||||
|
↓ (approve)
|
||||||
|
MGR_APPROVED → SENT_FOR_PAYMENT → PAID_DELIVERED → CLOSED
|
||||||
|
|
||||||
|
Re-entry branches from MGR_REVIEW:
|
||||||
|
→ REJECTED (terminal)
|
||||||
|
→ EDITS_REQUESTED → submitter edits → MGR_REVIEW
|
||||||
|
→ VENDOR_ID_PENDING → submitter picks vendor → MGR_REVIEW
|
||||||
|
```
|
||||||
|
|
||||||
|
Terminal states: REJECTED, CLOSED.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Entities
|
||||||
|
|
||||||
|
See `DESIGN.md` §8 for the full field list. Tables you'll need:
|
||||||
|
|
||||||
|
- `purchase_order` — id, title, status, total_amount, currency, date_required, project_code, manager_note, payment_ref, quotation_no/date, requisition_no/date, place_of_delivery, terms.{delivery,dispatch,inspection,insurance,payment,others}, all timestamps.
|
||||||
|
- `po_line_item` — fk po_id, name, description, qty, unit, size, unit_price, gst_rate (default 18), total (computed), sort_order, optional product_id.
|
||||||
|
- `vendor` — name, vendor_id (nullable unique), address, pincode, gstin, contact, latitude, longitude, verified, active.
|
||||||
|
- `product` — code, name, description, last_price, last_vendor_id, active.
|
||||||
|
- `product_vendor_price` — composite (product_id, vendor_id) → price, updated_at.
|
||||||
|
- `vessel` — name, imo, active, assigned_site_id.
|
||||||
|
- `site` — name, code, address, pincode, lat, lng, active.
|
||||||
|
- `account` — code, name, description, active.
|
||||||
|
- `user` — emp_id, email, name, role, active, password_hash.
|
||||||
|
- `item_inventory` — (product_id, site_id) → qty.
|
||||||
|
- `item_consumption` — (product_id, site_id, date) → qty.
|
||||||
|
- `po_audit_log` — fk po_id, actor_id, action, note, timestamp.
|
||||||
|
|
||||||
|
Auto-sync of `product` / `product_vendor_price` on PO `mark_paid` per spec §7.9.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files in this Bundle
|
||||||
|
|
||||||
|
| File | Role |
|
||||||
|
|------|------|
|
||||||
|
| `Pelagia Portal.html` | Entry point — references all scripts and styles |
|
||||||
|
| `styles.css` | Complete design system: tokens, components, layout, charts |
|
||||||
|
| `components.jsx` | Shared primitives — Icon, Badge, Card, Stat, Crumbs, format helpers |
|
||||||
|
| `data.jsx` | Mock data — vessels, accounts, vendors, products, sites, users, orders |
|
||||||
|
| `pages-1.jsx` | LoginPage, Dashboard, MyOrders |
|
||||||
|
| `pages-2.jsx` | ApprovalsPage, PODetailBase (shared by detail + approval), NewPOPage, ApprovalActions |
|
||||||
|
| `pages-3.jsx` | PaymentsPage, HistoryPage, VendorsPage + AddVendorPanel, VendorDetailPage, ItemsPage, ItemDetailPage |
|
||||||
|
| `pages-4.jsx` | SitesPage, SiteDetailPage, VesselsPage, AccountsPage, UsersPage, CartPage, ImportPOPage |
|
||||||
|
| `app.jsx` | Router, role-aware Sidebar, Header (with prototype role-switcher), App entrypoint |
|
||||||
|
| `DESIGN.md` | Original product specification — authoritative for behaviour, copy, and role matrix |
|
||||||
|
|
||||||
|
The fastest way to absorb the design: open `Pelagia Portal.html` in a browser, switch roles via the header dropdown, and click through the sidebar. Then port screen-by-screen using `DESIGN.md` as the source of truth for any behavioural ambiguity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assets
|
||||||
|
|
||||||
|
- **Fonts:** Geist + Geist Mono via Google Fonts. Both free and OFL-licensed.
|
||||||
|
- **Icons:** custom 16×16 monoline set; reproduce with Lucide React.
|
||||||
|
- **Logo / brand mark:** generated in CSS (a navy square with a clipped semicircle representing a wave/anchor abstract). Replace with the real Pelagia mark when available.
|
||||||
|
- **Map/photographic imagery:** none used. Vendor-detail "map" is an intentionally schematic placeholder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Out of Scope (per spec §10)
|
||||||
|
|
||||||
|
- Mobile app — web-only, desktop-first.
|
||||||
|
- Public-facing pages — entirely internal.
|
||||||
|
- Self-registration / OAuth — admin-created accounts only.
|
||||||
|
- Vendor portal — vendors do not log in.
|
||||||
|
- Automated bank/payment-gateway integration — payment is marked manually.
|
||||||
227
design_handoff_pelagia_portal/app.jsx
Normal file
227
design_handoff_pelagia_portal/app.jsx
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
/* Pelagia Portal — main app with router, sidebar, header */
|
||||||
|
|
||||||
|
const NAV_BY_ROLE = {
|
||||||
|
TECHNICAL: {
|
||||||
|
main: [["dashboard", "Dashboard", "home"]],
|
||||||
|
"Purchase Orders": [
|
||||||
|
["po-new", "New PO", "plus"],
|
||||||
|
["my-orders", "My Orders", "list", 3],
|
||||||
|
],
|
||||||
|
Inventory: [
|
||||||
|
["items", "Items", "box"],
|
||||||
|
["cart", "Cart", "cart", 3],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MANNING: {
|
||||||
|
main: [["dashboard", "Dashboard", "home"]],
|
||||||
|
"Purchase Orders": [
|
||||||
|
["po-new", "New PO", "plus"],
|
||||||
|
["my-orders", "My Orders", "list", 2],
|
||||||
|
],
|
||||||
|
Inventory: [
|
||||||
|
["items", "Items", "box"],
|
||||||
|
["cart", "Cart", "cart"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
MANAGER: {
|
||||||
|
main: [["dashboard", "Dashboard", "home"]],
|
||||||
|
"Purchase Orders": [
|
||||||
|
["po-new", "New PO", "plus"],
|
||||||
|
["my-orders", "My Orders", "list"],
|
||||||
|
["approvals", "Approvals", "check", 6],
|
||||||
|
["import-po", "Import PO", "upload"],
|
||||||
|
["history", "History / Export", "file"],
|
||||||
|
],
|
||||||
|
Inventory: [
|
||||||
|
["vendors", "Vendors", "users"],
|
||||||
|
["items", "Items", "box"],
|
||||||
|
["vessels", "Vessels", "ship"],
|
||||||
|
["sites", "Sites", "map"],
|
||||||
|
["cart", "Cart", "cart"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ACCOUNTS: {
|
||||||
|
main: [["dashboard", "Dashboard", "home"]],
|
||||||
|
"Purchase Orders": [
|
||||||
|
["payments", "Payments", "truck", 4],
|
||||||
|
["history", "History / Export", "file"],
|
||||||
|
],
|
||||||
|
Inventory: [
|
||||||
|
["vendors", "Vendors", "users"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
SUPERUSER: {
|
||||||
|
main: [["dashboard", "Dashboard", "home"]],
|
||||||
|
"Purchase Orders": [
|
||||||
|
["po-new", "New PO", "plus"],
|
||||||
|
["my-orders", "My Orders", "list"],
|
||||||
|
["approvals", "Approvals", "check", 6],
|
||||||
|
["payments", "Payments", "truck", 4],
|
||||||
|
["import-po", "Import PO", "upload"],
|
||||||
|
["history", "History / Export", "file"],
|
||||||
|
],
|
||||||
|
Inventory: [
|
||||||
|
["vendors", "Vendors", "users"],
|
||||||
|
["items", "Items", "box"],
|
||||||
|
["vessels", "Vessels", "ship"],
|
||||||
|
["sites", "Sites", "map"],
|
||||||
|
["cart", "Cart", "cart"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
AUDITOR: {
|
||||||
|
main: [["dashboard", "Dashboard", "home"]],
|
||||||
|
"Purchase Orders": [
|
||||||
|
["history", "History / Export", "file"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
ADMIN: {
|
||||||
|
main: [["dashboard", "Dashboard", "home"]],
|
||||||
|
"Purchase Orders": [
|
||||||
|
["import-po", "Import PO", "upload"],
|
||||||
|
["history", "History / Export", "file"],
|
||||||
|
],
|
||||||
|
Inventory: [
|
||||||
|
["vendors", "Vendors", "users"],
|
||||||
|
["items", "Items", "box"],
|
||||||
|
["vessels", "Vessels", "ship"],
|
||||||
|
["sites", "Sites", "map"],
|
||||||
|
],
|
||||||
|
Administration: [
|
||||||
|
["users", "Users", "user"],
|
||||||
|
["accounts", "Accounts", "settings"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const USER_BY_ROLE = {
|
||||||
|
TECHNICAL: { name: "Rajesh Pillai", short: "RP" },
|
||||||
|
MANNING: { name: "Fatima Sheikh", short: "FS" },
|
||||||
|
MANAGER: { name: "Anjali Krishnan", short: "AK" },
|
||||||
|
ACCOUNTS: { name: "Vikram Iyer", short: "VI" },
|
||||||
|
SUPERUSER: { name: "Dev Shah", short: "DS" },
|
||||||
|
AUDITOR: { name: "Lakshmi Rao", short: "LR" },
|
||||||
|
ADMIN: { name: "System Admin", short: "SA" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const Sidebar = ({ role, route, navigate }) => {
|
||||||
|
const nav = NAV_BY_ROLE[role] || NAV_BY_ROLE.MANAGER;
|
||||||
|
return (
|
||||||
|
<aside className="sidebar">
|
||||||
|
{nav.main && nav.main.map(([key, label, icon, count]) => (
|
||||||
|
<div key={key} className={`nav-item ${route.page === key ? "active" : ""}`} onClick={() => navigate(key)}>
|
||||||
|
<Icon name={icon} />
|
||||||
|
<span>{label}</span>
|
||||||
|
{count != null && <span className="nav-count">{count}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.entries(nav).filter(([k]) => k !== "main").map(([groupName, items]) => (
|
||||||
|
<div className="nav-group" key={groupName}>
|
||||||
|
<div className="nav-group-title">{groupName}</div>
|
||||||
|
{items.map(([key, label, icon, count]) => (
|
||||||
|
<div key={key} className={`nav-item ${route.page === key ? "active" : ""}`} onClick={() => navigate(key)}>
|
||||||
|
<Icon name={icon} />
|
||||||
|
<span>{label}</span>
|
||||||
|
{count != null && <span className="nav-count">{count}</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Header = ({ role, setRole, navigate }) => {
|
||||||
|
const u = USER_BY_ROLE[role];
|
||||||
|
return (
|
||||||
|
<header className="app-header">
|
||||||
|
<div className="brand" onClick={() => navigate("dashboard")} style={{ cursor: "pointer" }}>
|
||||||
|
<div className="brand-mark" />
|
||||||
|
<div className="brand-name">Pelagia Portal</div>
|
||||||
|
</div>
|
||||||
|
<div className="header-search">
|
||||||
|
<Icon name="search" size={12} />
|
||||||
|
<input placeholder="Search POs, vendors, items…" />
|
||||||
|
<span className="kbd">⌘K</span>
|
||||||
|
</div>
|
||||||
|
<div className="header-spacer" />
|
||||||
|
<div className="role-pill" title="Switch role to preview navigation (prototype only)">
|
||||||
|
<span className="muted">Role</span>
|
||||||
|
<select value={role} onChange={e => setRole(e.target.value)}>
|
||||||
|
{Object.keys(NAV_BY_ROLE).map(r => <option key={r} value={r}>{r}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="btn icon" title="Notifications"><Icon name="bell" size={13} /></button>
|
||||||
|
<div className="user-chip">
|
||||||
|
<div className="user-avatar">{u.short}</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{u.name}</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>{role.toLowerCase()}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const [authed, setAuthed] = useS(() => sessionStorage.getItem("pelagia.authed") === "1");
|
||||||
|
const [role, setRole] = useS(() => sessionStorage.getItem("pelagia.role") || "MANAGER");
|
||||||
|
const [route, setRoute] = useS(() => parseHash() || { page: "dashboard" });
|
||||||
|
|
||||||
|
useE(() => {
|
||||||
|
const onHash = () => setRoute(parseHash() || { page: "dashboard" });
|
||||||
|
window.addEventListener("hashchange", onHash);
|
||||||
|
return () => window.removeEventListener("hashchange", onHash);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useE(() => sessionStorage.setItem("pelagia.role", role), [role]);
|
||||||
|
useE(() => sessionStorage.setItem("pelagia.authed", authed ? "1" : "0"), [authed]);
|
||||||
|
|
||||||
|
const navigate = (page, id) => {
|
||||||
|
window.location.hash = id ? `#${page}/${id}` : `#${page}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!authed) return <LoginPage onLogin={() => setAuthed(true)} />;
|
||||||
|
|
||||||
|
const page = route.page;
|
||||||
|
const id = route.id;
|
||||||
|
let body;
|
||||||
|
switch (page) {
|
||||||
|
case "dashboard": body = <Dashboard role={role} go={navigate} />; break;
|
||||||
|
case "my-orders": body = <MyOrders go={navigate} />; break;
|
||||||
|
case "approvals": body = <ApprovalsPage go={navigate} />; break;
|
||||||
|
case "approval-detail": body = <PODetailBase isApproval order={ORDERS.find(o => o.id === id) || APPROVAL_QUEUE.find(o => o.id === id) || ORDERS[0]} go={navigate} role={role} />; break;
|
||||||
|
case "po-detail": body = <PODetailBase order={ORDERS.find(o => o.id === id) || ORDERS[0]} go={navigate} role={role} />; break;
|
||||||
|
case "po-new": body = <NewPOPage go={navigate} />; break;
|
||||||
|
case "payments": body = <PaymentsPage go={navigate} />; break;
|
||||||
|
case "history": body = <HistoryPage go={navigate} />; break;
|
||||||
|
case "vendors": body = <VendorsPage go={navigate} />; break;
|
||||||
|
case "vendor-detail": body = <VendorDetailPage go={navigate} id={id} />; break;
|
||||||
|
case "items": body = <ItemsPage go={navigate} />; break;
|
||||||
|
case "item-detail": body = <ItemDetailPage go={navigate} id={id} />; break;
|
||||||
|
case "sites": body = <SitesPage go={navigate} />; break;
|
||||||
|
case "site-detail": body = <SiteDetailPage go={navigate} id={id} />; break;
|
||||||
|
case "vessels": body = <VesselsPage />; break;
|
||||||
|
case "accounts": body = <AccountsPage />; break;
|
||||||
|
case "users": body = <UsersPage />; break;
|
||||||
|
case "import-po": body = <ImportPOPage go={navigate} />; break;
|
||||||
|
case "cart": body = <CartPage go={navigate} />; break;
|
||||||
|
default: body = <Dashboard role={role} go={navigate} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<Header role={role} setRole={setRole} navigate={navigate} />
|
||||||
|
<Sidebar role={role} route={route} navigate={navigate} />
|
||||||
|
<main className="main" key={page + (id || "")}>{body}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseHash() {
|
||||||
|
const h = window.location.hash.replace(/^#/, "");
|
||||||
|
if (!h) return null;
|
||||||
|
const [page, id] = h.split("/");
|
||||||
|
return { page, id };
|
||||||
|
}
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
|
||||||
104
design_handoff_pelagia_portal/components.jsx
Normal file
104
design_handoff_pelagia_portal/components.jsx
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
/* Shared components for Pelagia Portal */
|
||||||
|
|
||||||
|
const { useState, useEffect, useRef, useMemo } = React;
|
||||||
|
|
||||||
|
/* ─────────── Icons (inline SVG, 14×14) ─────────── */
|
||||||
|
const Icon = ({ name, size = 14 }) => {
|
||||||
|
const paths = {
|
||||||
|
home: <><path d="M2 7l6-5 6 5v7a1 1 0 0 1-1 1h-3v-5H6v5H3a1 1 0 0 1-1-1V7z"/></>,
|
||||||
|
file: <><path d="M3 1.5h6l3.5 3.5v9a1 1 0 0 1-1 1h-8.5a1 1 0 0 1-1-1v-12a1 1 0 0 1 1-1z M9 1.5V5h3.5"/></>,
|
||||||
|
list: <><path d="M2 4h12 M2 8h12 M2 12h12"/></>,
|
||||||
|
check: <><path d="M3.5 8L7 11.5L13 4.5"/></>,
|
||||||
|
cart: <><path d="M2 2h2l2 9h8l1.5-6h-9 M6.5 13.5a1 1 0 1 0 0-.001 M12.5 13.5a1 1 0 1 0 0-.001"/></>,
|
||||||
|
box: <><path d="M2 4.5l6-2.5 6 2.5v7l-6 2.5-6-2.5v-7z M2 4.5l6 2.5 6-2.5 M8 7v7.5"/></>,
|
||||||
|
truck: <><path d="M1 3.5h8.5v7.5h-8.5z M9.5 6h3l2 2.5v2.5h-5z M4 12.5a1.25 1.25 0 1 0 0-.01 M11.5 12.5a1.25 1.25 0 1 0 0-.01"/></>,
|
||||||
|
users: <><path d="M5.5 8a2 2 0 1 0 0-4 2 2 0 0 0 0 4z M1.5 14c0-2 1.5-3.5 4-3.5s4 1.5 4 3.5 M10.5 7.5a1.75 1.75 0 1 0 0-3.5 M14.5 13c0-1.6-1.2-2.8-3-2.8"/></>,
|
||||||
|
ship: <><path d="M2 11h12l-1 3h-10z M3 11V6l5-2 5 2v5 M8 4v-2 M5.5 8h5"/></>,
|
||||||
|
map: <><path d="M5.5 1.5l-3.5 1.5v11l3.5-1.5 5 1.5 3.5-1.5v-11l-3.5 1.5-5-1.5z M5.5 1.5v11 M10.5 3v11"/></>,
|
||||||
|
chart: <><path d="M2 13V3 M2 13h12 M5 11V8 M8 11V5 M11 11V7"/></>,
|
||||||
|
user: <><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6z M2 14c0-3 2.5-5 6-5s6 2 6 5"/></>,
|
||||||
|
settings: <><path d="M8 5.5a2.5 2.5 0 1 0 0 5a2.5 2.5 0 0 0 0-5z M8 1v1.5 M8 13.5V15 M2.5 5L4 6 M12 10l1.5 1 M2.5 11L4 10 M12 6l1.5-1 M1 8h1.5 M13.5 8H15"/></>,
|
||||||
|
plus: <><path d="M8 3v10 M3 8h10"/></>,
|
||||||
|
download: <><path d="M8 2v9 M4 7l4 4 4-4 M2.5 13.5h11"/></>,
|
||||||
|
upload: <><path d="M8 11V2 M4 6l4-4 4 4 M2.5 13.5h11"/></>,
|
||||||
|
search: <><circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5L14 14"/></>,
|
||||||
|
chevron: <><path d="M5 3l5 5-5 5"/></>,
|
||||||
|
star: <><path d="M8 1.5l2 4.5 5 .5-3.7 3.4 1 5-4.3-2.5-4.3 2.5 1-5L1 6.5l5-.5z"/></>,
|
||||||
|
edit: <><path d="M11 2l3 3-8.5 8.5h-3v-3z M9.5 3.5l3 3"/></>,
|
||||||
|
trash: <><path d="M3 4h10 M5 4V2.5h6V4 M4.5 4l.5 9.5a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1l.5-9.5"/></>,
|
||||||
|
bell: <><path d="M8 1.5a4 4 0 0 1 4 4v3l1.5 2h-11l1.5-2v-3a4 4 0 0 1 4-4z M6 11.5a2 2 0 0 0 4 0"/></>,
|
||||||
|
eye: <><path d="M1 8s2.5-4.5 7-4.5S15 8 15 8s-2.5 4.5-7 4.5S1 8 1 8z"/><circle cx="8" cy="8" r="2"/></>,
|
||||||
|
refresh: <><path d="M13.5 8a5.5 5.5 0 0 1-9.5 3.5 M13.5 4v3.5h-3.5 M2.5 8a5.5 5.5 0 0 1 9.5-3.5 M2.5 12V8.5H6"/></>,
|
||||||
|
paperclip: <><path d="M12 7l-5 5a3 3 0 1 1-4.2-4.2L9 1.5a2 2 0 1 1 3 3L5.5 11a1 1 0 1 1-1.5-1.5L10 3.5"/></>,
|
||||||
|
arrowRight: <><path d="M3 8h10 M9 4l4 4-4 4"/></>,
|
||||||
|
pkg: <><path d="M2 4.5L8 2l6 2.5V11L8 13.5 2 11z M2 4.5L8 7l6-2.5 M8 7v6.5"/></>,
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<svg className="nav-icon" width={size} height={size} viewBox="0 0 16 16"
|
||||||
|
fill="none" stroke="currentColor" strokeWidth="1.4"
|
||||||
|
strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
{paths[name] || null}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─────────── Status Badge ─────────── */
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
DRAFT: ["draft", "Draft"],
|
||||||
|
SUBMITTED: ["submitted", "Submitted"],
|
||||||
|
MGR_REVIEW: ["review", "Under Review"],
|
||||||
|
VENDOR_ID_PENDING: ["vendor", "Vendor Needed"],
|
||||||
|
EDITS_REQUESTED: ["edits", "Edits Requested"],
|
||||||
|
MGR_APPROVED: ["approved", "Approved"],
|
||||||
|
SENT_FOR_PAYMENT: ["sent", "Payment Sent"],
|
||||||
|
PAID_DELIVERED: ["paid", "Paid · Awaiting Receipt"],
|
||||||
|
CLOSED: ["closed", "Closed"],
|
||||||
|
REJECTED: ["rejected", "Rejected"],
|
||||||
|
};
|
||||||
|
const Badge = ({ status, children, className = "", noDot }) => {
|
||||||
|
if (status) {
|
||||||
|
const [cls, label] = STATUS_LABELS[status] || ["draft", status];
|
||||||
|
return <span className={`badge ${cls} ${noDot ? "no-dot" : ""} ${className}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
return <span className={`badge ${className} ${noDot ? "no-dot" : ""}`}>{children}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ─────────── Format helpers ─────────── */
|
||||||
|
const inr = (n) => "₹" + Number(n).toLocaleString("en-IN", { maximumFractionDigits: 0 });
|
||||||
|
const inrFull = (n) => "₹" + Number(n).toLocaleString("en-IN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
|
||||||
|
/* ─────────── Card ─────────── */
|
||||||
|
const Card = ({ title, action, children, flush, className = "" }) => (
|
||||||
|
<div className={`card ${className}`}>
|
||||||
|
{(title || action) && (
|
||||||
|
<div className="card-head">
|
||||||
|
<h3 className="card-title">{title}</h3>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`card-body ${flush ? "flush" : ""}`}>{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ─────────── Stat tile ─────────── */
|
||||||
|
const Stat = ({ label, value, sub, onClick }) => (
|
||||||
|
<div className={`stat ${onClick ? "clickable" : ""}`} onClick={onClick}>
|
||||||
|
<div className="stat-label">{label}</div>
|
||||||
|
<div className="stat-value">{value}</div>
|
||||||
|
{sub && <div className="stat-sub">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ─────────── Crumbs ─────────── */
|
||||||
|
const Crumbs = ({ items }) => (
|
||||||
|
<div className="crumbs">
|
||||||
|
{items.map((it, i) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{i > 0 && <span className="sep">/</span>}
|
||||||
|
<span style={{ color: i === items.length - 1 ? "var(--ink-2)" : undefined }}>{it}</span>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(window, { Icon, Badge, Card, Stat, Crumbs, inr, inrFull, STATUS_LABELS });
|
||||||
234
design_handoff_pelagia_portal/data.jsx
Normal file
234
design_handoff_pelagia_portal/data.jsx
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
/* Mock data for Pelagia Portal */
|
||||||
|
|
||||||
|
const VESSELS = [
|
||||||
|
{ id: "v1", name: "MV Pelagia Voyager", imo: "IMO 9342711" },
|
||||||
|
{ id: "v2", name: "MV Coral Crescent", imo: "IMO 9518342" },
|
||||||
|
{ id: "v3", name: "MV Arctic Halo", imo: "IMO 9712095" },
|
||||||
|
{ id: "v4", name: "MV North Drift", imo: "IMO 9665203" },
|
||||||
|
{ id: "v5", name: "MV Saffron Star", imo: "IMO 9883017" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ACCOUNTS = [
|
||||||
|
{ id: "a1", code: "VC-OPS", name: "Voyager Operations", desc: "Fleet operations cost centre" },
|
||||||
|
{ id: "a2", code: "DD-2026", name: "Dry Dock 2026", desc: "Scheduled dry-docking expenses" },
|
||||||
|
{ id: "a3", code: "PROV-MAR", name: "Provisions — Mar", desc: "Crew provisions monthly" },
|
||||||
|
{ id: "a4", code: "ENGINE", name: "Engine Department", desc: "Engine spares & consumables" },
|
||||||
|
{ id: "a5", code: "DECK", name: "Deck Department", desc: "Deck spares & consumables" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VENDORS = [
|
||||||
|
{ id: "vn1", vid: "VID-00214", name: "Mahalakshmi Marine Stores", gstin: "29ABCDE1234F1Z5", contact: "R. Subramanian", email: "sales@mahalakshmimarine.in", phone: "+91 98450 12011", pin: "682002", verified: true, active: true, city: "Kochi" },
|
||||||
|
{ id: "vn2", vid: "VID-00339", name: "Coastline Engineering Co.", gstin: "27FGHIJ5678K2Z3", contact: "Priya Nair", email: "po@coastline-eng.com", phone: "+91 91670 88324", pin: "400062", verified: true, active: true, city: "Mumbai" },
|
||||||
|
{ id: "vn3", vid: "VID-00402", name: "Bluewater Provisions", gstin: "33LMNOP1245Q1Z7", contact: "Karthik V.", email: "orders@bluewaterprov.in", phone: "+91 90030 41560", pin: "600002", verified: true, active: true, city: "Chennai" },
|
||||||
|
{ id: "vn4", vid: null, name: "Anchor Supply Traders", gstin: "06RSTUV4567W1Z9", contact: "Tarun Mehta", email: "tarun@anchorsupply.in", phone: "+91 88260 19972", pin: "122002", verified: false, active: true, city: "Gurugram" },
|
||||||
|
{ id: "vn5", vid: "VID-00128", name: "Konark Industrial Spares", gstin: "24WXYZ7890A1Z2", contact: "Meera Joshi", email: "meera@konark.co.in", phone: "+91 99250 33041", pin: "395003", verified: true, active: true, city: "Surat" },
|
||||||
|
{ id: "vn6", vid: "VID-00501", name: "Sealine Maritime Pvt Ltd", gstin: "32BCDEF0192G1Z4", contact: "Anu Pillai", email: "buying@sealine.in", phone: "+91 90490 21188", pin: "682304", verified: true, active: false, city: "Kochi" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRODUCTS = [
|
||||||
|
{ id: "p1", code: "ENG-BR-040", name: "Marine Bearing 6310-2RS", desc: "Sealed deep-groove bearing, 50×110×27mm", vendors: 4, lastPrice: 4250, lastVendor: "Mahalakshmi Marine Stores", updated: "Apr 22, 2026", active: true },
|
||||||
|
{ id: "p2", code: "FLT-OIL-LO", name: "Lube Oil Filter — Element", desc: "Cellulose element, 10µ rated", vendors: 5, lastPrice: 1180, lastVendor: "Coastline Engineering", updated: "May 02, 2026", active: true },
|
||||||
|
{ id: "p3", code: "DCK-RP-024", name: "Mooring Rope — 24mm Polyester", desc: "8-strand, 200m coil, breaking load 11.2t", vendors: 3, lastPrice: 38900, lastVendor: "Konark Industrial Spares", updated: "Apr 11, 2026", active: true },
|
||||||
|
{ id: "p4", code: "PRV-RIC-25", name: "Basmati Rice — 25kg", desc: "Aged, sortex-cleaned", vendors: 6, lastPrice: 2200, lastVendor: "Bluewater Provisions", updated: "May 10, 2026", active: true },
|
||||||
|
{ id: "p5", code: "SAF-EXT-09", name: "CO₂ Fire Extinguisher 9kg", desc: "Marine-grade, BIS certified", vendors: 4, lastPrice: 8400, lastVendor: "Sealine Maritime", updated: "Mar 30, 2026", active: true },
|
||||||
|
{ id: "p6", code: "ENG-GSK-12", name: "Cylinder Head Gasket Set", desc: "MAN B&W L28/32H spares", vendors: 2, lastPrice: 18750, lastVendor: "Coastline Engineering", updated: "Feb 18, 2026", active: true },
|
||||||
|
{ id: "p7", code: "DCK-CHN-16", name: "Anchor Chain Shackle — D-type", desc: "16mm forged, galvanised", vendors: 3, lastPrice: 3200, lastVendor: "Konark Industrial Spares", updated: "Apr 29, 2026", active: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SITES = [
|
||||||
|
{ id: "s1", name: "Cochin Port Depot", code: "COK-D1", address: "Willingdon Island, Kochi 682003", pin: "682003", vessels: 2, items: 142, status: "active" },
|
||||||
|
{ id: "s2", name: "Mumbai BPX Office", code: "BOM-O1", address: "Ballard Estate, Mumbai 400001", pin: "400001", vessels: 1, items: 38, status: "active" },
|
||||||
|
{ id: "s3", name: "Chennai South Dock", code: "MAA-D1", address: "Chennai Port Trust, 600001", pin: "600001", vessels: 1, items: 96, status: "active" },
|
||||||
|
{ id: "s4", name: "Visakhapatnam Yard", code: "VTZ-Y1", address: "Outer Harbour Rd, 530035", pin: "530035", vessels: 1, items: 64, status: "active" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const USERS = [
|
||||||
|
{ id: "u1", emp: "EMP-1042", name: "Anjali Krishnan", email: "anjali.k@pelagia.co", role: "MANAGER", status: "active", created: "Sep 14, 2024" },
|
||||||
|
{ id: "u2", emp: "EMP-1058", name: "Vikram Iyer", email: "vikram.i@pelagia.co", role: "ACCOUNTS", status: "active", created: "Oct 03, 2024" },
|
||||||
|
{ id: "u3", emp: "EMP-1077", name: "Rajesh Pillai", email: "rajesh.p@pelagia.co", role: "TECHNICAL", status: "active", created: "Nov 21, 2024" },
|
||||||
|
{ id: "u4", emp: "EMP-1083", name: "Fatima Sheikh", email: "fatima.s@pelagia.co", role: "MANNING", status: "active", created: "Dec 05, 2024" },
|
||||||
|
{ id: "u5", emp: "EMP-1091", name: "Dev Shah", email: "dev.s@pelagia.co", role: "SUPERUSER", status: "active", created: "Jan 17, 2025" },
|
||||||
|
{ id: "u6", emp: "EMP-1106", name: "Lakshmi Rao", email: "lakshmi.r@pelagia.co",role: "AUDITOR", status: "active", created: "Feb 02, 2025" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ORDERS = [
|
||||||
|
{
|
||||||
|
id: "PO-2026-00481", title: "Engine room bearings — Q2 replenishment",
|
||||||
|
vessel: "MV Pelagia Voyager", account: "ENGINE", vendor: "Mahalakshmi Marine Stores", vendorId: "vn1",
|
||||||
|
submitter: "Rajesh Pillai", status: "MGR_REVIEW",
|
||||||
|
amount: 248300, currency: "INR", project: "ER-MAINT-2026",
|
||||||
|
submitted: "May 11, 2026 · 09:42", created: "May 10, 2026", required: "May 28, 2026",
|
||||||
|
items: [
|
||||||
|
{ name: "Marine Bearing 6310-2RS", desc: "50×110×27mm, sealed", qty: 24, unit: "ea", price: 4250, gst: 18 },
|
||||||
|
{ name: "Marine Bearing 6207-ZZ", desc: "35×72×17mm, shielded", qty: 36, unit: "ea", price: 1850, gst: 18 },
|
||||||
|
{ name: "Cylinder Head Gasket Set", desc: "MAN B&W L28/32H", qty: 4, unit: "set", price: 18750, gst: 18 },
|
||||||
|
],
|
||||||
|
terms: {
|
||||||
|
delivery: "FOB Cochin Port. Vendor responsible until cargo cleared at gate.",
|
||||||
|
dispatch: "Within 14 days of approval.",
|
||||||
|
inspection: "Joint inspection at vendor warehouse prior to dispatch.",
|
||||||
|
insurance: "Transit insurance included in vendor scope.",
|
||||||
|
payment: "30 days from receipt of goods and invoice.",
|
||||||
|
others: "All items to be packed with desiccant and shrink-wrapped."
|
||||||
|
},
|
||||||
|
docs: [
|
||||||
|
{ name: "Vendor Quotation Q-2841.pdf", size: "412 KB" },
|
||||||
|
{ name: "Engine Inspection Report.pdf", size: "1.8 MB" }
|
||||||
|
],
|
||||||
|
audit: [
|
||||||
|
{ who: "Rajesh Pillai", what: "Created draft", when: "May 10, 2026 · 16:24", done: true },
|
||||||
|
{ who: "Rajesh Pillai", what: "Submitted for approval", when: "May 11, 2026 · 09:42", done: true },
|
||||||
|
{ who: "Anjali Krishnan", what: "Opened for review", when: "May 11, 2026 · 14:15", done: true },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00478", title: "Bridge electronics — radar maintenance",
|
||||||
|
vessel: "MV Coral Crescent", account: "VC-OPS", vendor: "Coastline Engineering Co.", vendorId: "vn2",
|
||||||
|
submitter: "Fatima Sheikh", status: "MGR_APPROVED",
|
||||||
|
amount: 84200, currency: "INR", project: "NAV-RADAR",
|
||||||
|
submitted: "May 09, 2026", created: "May 08, 2026", required: "Jun 02, 2026", approved: "May 10, 2026",
|
||||||
|
items: [
|
||||||
|
{ name: "Radar Magnetron — X-band", desc: "Replacement unit", qty: 1, unit: "ea", price: 67500, gst: 18 },
|
||||||
|
{ name: "RF Connector N-type", desc: "Bulkhead, IP67", qty: 6, unit: "ea", price: 850, gst: 18 },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00475", title: "Provisions — March crew rotation",
|
||||||
|
vessel: "MV Arctic Halo", account: "PROV-MAR", vendor: "Bluewater Provisions", vendorId: "vn3",
|
||||||
|
submitter: "Rajesh Pillai", status: "SENT_FOR_PAYMENT",
|
||||||
|
amount: 142800, currency: "INR", project: null,
|
||||||
|
submitted: "May 04, 2026", required: "May 18, 2026", approved: "May 06, 2026",
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00472", title: "Mooring ropes — port-side replacement",
|
||||||
|
vessel: "MV North Drift", account: "DECK", vendor: "Konark Industrial Spares", vendorId: "vn5",
|
||||||
|
submitter: "Fatima Sheikh", status: "EDITS_REQUESTED",
|
||||||
|
amount: 198000, currency: "INR", project: "DCK-Q2",
|
||||||
|
submitted: "May 02, 2026", required: "May 22, 2026",
|
||||||
|
managerNote: "Please split into two POs — port and starboard side — as the budget owner differs. Also confirm 200m or 300m coil length.",
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00469", title: "Fire safety equipment — annual recert",
|
||||||
|
vessel: "MV Saffron Star", account: "VC-OPS", vendor: "Sealine Maritime Pvt Ltd", vendorId: "vn6",
|
||||||
|
submitter: "Dev Shah", status: "PAID_DELIVERED",
|
||||||
|
amount: 67200, currency: "INR", project: null,
|
||||||
|
submitted: "Apr 28, 2026", required: "May 12, 2026", approved: "Apr 30, 2026", paid: "May 07, 2026",
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00466", title: "Lube oil filter elements — bulk",
|
||||||
|
vessel: "MV Pelagia Voyager", account: "ENGINE", vendor: "Coastline Engineering Co.", vendorId: "vn2",
|
||||||
|
submitter: "Rajesh Pillai", status: "CLOSED",
|
||||||
|
amount: 38700, currency: "INR", project: "ER-MAINT-2026",
|
||||||
|
submitted: "Apr 22, 2026", approved: "Apr 24, 2026", paid: "Apr 30, 2026", closed: "May 06, 2026",
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00463", title: "Cabin linens & cleaning supplies",
|
||||||
|
vessel: "MV Coral Crescent", account: "PROV-MAR", vendor: null,
|
||||||
|
submitter: "Fatima Sheikh", status: "VENDOR_ID_PENDING",
|
||||||
|
amount: 24600, currency: "INR", project: null,
|
||||||
|
submitted: "Apr 21, 2026", items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00459", title: "Anchor chain inspection tools",
|
||||||
|
vessel: "MV Arctic Halo", account: "DECK", vendor: "Mahalakshmi Marine Stores", vendorId: "vn1",
|
||||||
|
submitter: "Rajesh Pillai", status: "DRAFT",
|
||||||
|
amount: 14200, currency: "INR", project: null,
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00456", title: "Welding consumables — Q2",
|
||||||
|
vessel: "MV North Drift", account: "ENGINE", vendor: "Konark Industrial Spares", vendorId: "vn5",
|
||||||
|
submitter: "Dev Shah", status: "REJECTED",
|
||||||
|
amount: 92400, currency: "INR", project: "ER-MAINT-2026",
|
||||||
|
submitted: "Apr 19, 2026", rejected: "Apr 21, 2026",
|
||||||
|
managerNote: "Rejected — duplicate of PO-2026-00451. Use existing requisition.",
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00453", title: "Galley equipment — coffee maker",
|
||||||
|
vessel: "MV Saffron Star", account: "PROV-MAR", vendor: "Bluewater Provisions", vendorId: "vn3",
|
||||||
|
submitter: "Fatima Sheikh", status: "MGR_APPROVED",
|
||||||
|
amount: 18900, currency: "INR", project: null,
|
||||||
|
submitted: "Apr 16, 2026", approved: "Apr 17, 2026",
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00451", title: "Welding consumables — Q2",
|
||||||
|
vessel: "MV North Drift", account: "ENGINE", vendor: "Konark Industrial Spares", vendorId: "vn5",
|
||||||
|
submitter: "Rajesh Pillai", status: "CLOSED",
|
||||||
|
amount: 88300, currency: "INR", project: "ER-MAINT-2026",
|
||||||
|
items: []
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Approval queue derived
|
||||||
|
const APPROVAL_QUEUE = ORDERS.filter(o => o.status === "MGR_REVIEW").concat([
|
||||||
|
{
|
||||||
|
id: "PO-2026-00485", title: "Hydraulic hose replacement",
|
||||||
|
vessel: "MV Saffron Star", account: "ENGINE", vendor: "Coastline Engineering Co.",
|
||||||
|
submitter: "Dev Shah", status: "MGR_REVIEW",
|
||||||
|
amount: 62400, submitted: "May 12, 2026 · 11:08"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00483", title: "Navigation chart subscriptions Q2",
|
||||||
|
vessel: "MV Coral Crescent", account: "VC-OPS", vendor: "Sealine Maritime Pvt Ltd",
|
||||||
|
submitter: "Fatima Sheikh", status: "MGR_REVIEW",
|
||||||
|
amount: 31800, submitted: "May 12, 2026 · 08:30"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "PO-2026-00482", title: "Crew safety boots — 24 pairs",
|
||||||
|
vessel: "MV Arctic Halo", account: "DECK", vendor: "Anchor Supply Traders",
|
||||||
|
submitter: "Rajesh Pillai", status: "MGR_REVIEW",
|
||||||
|
amount: 96000, submitted: "May 11, 2026 · 16:55"
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Spend trend (last 8 months)
|
||||||
|
const SPEND_TREND = [
|
||||||
|
{ m: "Oct", v: 1240 }, { m: "Nov", v: 980 }, { m: "Dec", v: 1410 },
|
||||||
|
{ m: "Jan", v: 1820 }, { m: "Feb", v: 1580 }, { m: "Mar", v: 2110 },
|
||||||
|
{ m: "Apr", v: 1940 }, { m: "May", v: 1340 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VESSEL_SPEND = [
|
||||||
|
{ name: "MV Pelagia Voyager", v: 2840 },
|
||||||
|
{ name: "MV Coral Crescent", v: 1620 },
|
||||||
|
{ name: "MV Arctic Halo", v: 1180 },
|
||||||
|
{ name: "MV North Drift", v: 920 },
|
||||||
|
{ name: "MV Saffron Star", v: 760 },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Vendor pricing for Item Detail
|
||||||
|
const ITEM_VENDORS = [
|
||||||
|
{ vendor: "Mahalakshmi Marine Stores", verified: true, price: 4250, distance: 12, updated: "Apr 22, 2026", closest: true },
|
||||||
|
{ vendor: "Coastline Engineering Co.", verified: true, price: 4380, distance: 1240, updated: "Mar 18, 2026" },
|
||||||
|
{ vendor: "Konark Industrial Spares", verified: true, price: 4520, distance: 1820, updated: "Feb 02, 2026" },
|
||||||
|
{ vendor: "Anchor Supply Traders", verified: false, price: 4180, distance: 2410, updated: "Jan 28, 2026" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Site inventory mock
|
||||||
|
const SITE_INVENTORY = [
|
||||||
|
{ name: "Marine Bearing 6310-2RS", qty: 18, updated: "May 11" },
|
||||||
|
{ name: "Lube Oil Filter — Element", qty: 64, updated: "May 09" },
|
||||||
|
{ name: "CO₂ Fire Extinguisher 9kg", qty: 12, updated: "May 03" },
|
||||||
|
{ name: "Anchor Chain Shackle", qty: 22, updated: "Apr 28" },
|
||||||
|
{ name: "Basmati Rice — 25kg", qty: 8, updated: "May 10" },
|
||||||
|
{ name: "Welding Rods — E7018", qty: 40, updated: "May 02" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CONSUMPTION = [
|
||||||
|
{ d: 1, vals: [2,1,0,3,1] }, { d: 2, vals: [1,2,1,2,2] }, { d: 3, vals: [0,1,2,1,0] },
|
||||||
|
{ d: 4, vals: [3,2,0,2,1] }, { d: 5, vals: [2,3,1,1,2] }, { d: 6, vals: [1,1,2,0,3] },
|
||||||
|
{ d: 7, vals: [0,2,1,3,1] }, { d: 8, vals: [2,1,0,1,2] }, { d: 9, vals: [1,3,2,2,0] },
|
||||||
|
{ d: 10, vals: [3,2,1,0,2] }, { d: 11, vals: [2,1,3,1,1] }, { d: 12, vals: [1,2,0,2,3] },
|
||||||
|
];
|
||||||
|
|
||||||
|
Object.assign(window, {
|
||||||
|
VESSELS, ACCOUNTS, VENDORS, PRODUCTS, SITES, USERS, ORDERS,
|
||||||
|
APPROVAL_QUEUE, SPEND_TREND, VESSEL_SPEND, ITEM_VENDORS,
|
||||||
|
SITE_INVENTORY, CONSUMPTION
|
||||||
|
});
|
||||||
263
design_handoff_pelagia_portal/pages-1.jsx
Normal file
263
design_handoff_pelagia_portal/pages-1.jsx
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
/* Pelagia Portal — page components */
|
||||||
|
|
||||||
|
const { useState: useS, useEffect: useE, useMemo: useM } = React;
|
||||||
|
|
||||||
|
/* ═══════════════════ LOGIN ═══════════════════ */
|
||||||
|
const LoginPage = ({ onLogin }) => (
|
||||||
|
<div className="login-shell">
|
||||||
|
<div className="login-card">
|
||||||
|
<div className="login-brand">
|
||||||
|
<div className="brand-mark" />
|
||||||
|
<div>
|
||||||
|
<div className="brand-name" style={{ fontWeight: 600 }}>Pelagia Portal</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11.5, marginTop: 2 }}>Internal · Purchase Order Management</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ gap: 12 }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Email</label>
|
||||||
|
<input className="input" defaultValue="anjali.k@pelagia.co" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Password</label>
|
||||||
|
<input className="input" type="password" defaultValue="••••••••••" />
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime" style={{ height: 36, marginTop: 6, justifyContent: "center" }} onClick={onLogin}>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
<div className="faint" style={{ fontSize: 11.5, textAlign: "center" }}>
|
||||||
|
Contact an administrator to request access.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ DASHBOARD (Manager view) ═══════════════════ */
|
||||||
|
const Dashboard = ({ role, go }) => {
|
||||||
|
// Manager-rich view; switch headline content by role
|
||||||
|
const isMgr = role === "MANAGER" || role === "SUPERUSER";
|
||||||
|
const isAcct = role === "ACCOUNTS";
|
||||||
|
const isAuditor = role === "AUDITOR" || role === "ADMIN";
|
||||||
|
const isSubmitter = role === "TECHNICAL" || role === "MANNING";
|
||||||
|
|
||||||
|
const maxSpend = Math.max(...SPEND_TREND.map(s => s.v));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Dashboard"]} />
|
||||||
|
<h1 className="page-title">Good afternoon, Anjali</h1>
|
||||||
|
<div className="page-sub">Tuesday, May 12, 2026 · 14:38 IST · 3 vessels active at sea</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn" onClick={() => go("history")}><Icon name="download" /> Export</button>
|
||||||
|
<button className="btn maritime" onClick={() => go("po-new")}><Icon name="plus" /> New PO</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isMgr && (
|
||||||
|
<>
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
||||||
|
<Stat label="Awaiting approval" value="6" sub={<><span className="trend-up">↑ 2</span> vs last week</>} onClick={() => go("approvals")} />
|
||||||
|
<Stat label="Approved · May" value="42" sub={<span className="muted">across 5 vessels</span>} />
|
||||||
|
<Stat label="Approved spend" value="₹14.2L" sub={<><span className="trend-dn">↓ 8%</span> vs Apr</>} />
|
||||||
|
<Stat label="Avg cycle" value="3.4d" sub={<span className="muted">submit → approve</span>} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Card title="Monthly spend — last 8 months" action={<span className="muted" style={{ fontSize: 11.5 }}>₹ in thousands</span>}>
|
||||||
|
<div className="bar-chart" style={{ marginBottom: 22 }}>
|
||||||
|
{SPEND_TREND.map((s, i) => (
|
||||||
|
<div key={i} className="bar"
|
||||||
|
data-label={s.m}
|
||||||
|
style={{ height: (s.v / maxSpend * 130) + "px", background: i === SPEND_TREND.length - 1 ? "var(--primary)" : "var(--primary-soft)" }}>
|
||||||
|
<span className="bar-val">{s.v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card title="Spend by vessel — YTD" action={<span className="muted" style={{ fontSize: 11.5 }}>₹ in thousands</span>}>
|
||||||
|
<div style={{ paddingTop: 4 }}>
|
||||||
|
{VESSEL_SPEND.map((v, i) => {
|
||||||
|
const max = Math.max(...VESSEL_SPEND.map(x => x.v));
|
||||||
|
return (
|
||||||
|
<div className="hbar-row" key={i}>
|
||||||
|
<div className="name">{v.name}</div>
|
||||||
|
<div className="track"><div className="fill" style={{ width: (v.v / max * 100) + "%" }} /></div>
|
||||||
|
<div className="v">{v.v}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="Recently approved" action={<a className="muted" style={{ fontSize: 12 }} onClick={() => go("history")} href="#">View history →</a>} flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>PO Number</th><th>Title</th><th>Vessel</th>
|
||||||
|
<th>Submitter</th><th>Approved</th><th className="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ORDERS.filter(o => ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED"].includes(o.status)).slice(0, 5).map(o => (
|
||||||
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td>{o.title}</td>
|
||||||
|
<td className="muted">{o.vessel}</td>
|
||||||
|
<td className="muted">{o.submitter}</td>
|
||||||
|
<td className="muted">{o.approved || "—"}</td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAcct && (
|
||||||
|
<>
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
||||||
|
<Stat label="Ready for payment" value="4" onClick={() => go("payments")} />
|
||||||
|
<Stat label="Awaiting confirmation" value="2" />
|
||||||
|
<Stat label="Value awaiting payment" value="₹3.84L" />
|
||||||
|
<Stat label="Paid this month" value="₹11.6L" />
|
||||||
|
</div>
|
||||||
|
<Card title="Payments queue" action={<a onClick={() => go("payments")} className="muted" style={{ fontSize: 12 }}>Open queue →</a>}>
|
||||||
|
<div className="muted">4 POs ready to send for payment. Open the queue to process.</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSubmitter && (
|
||||||
|
<>
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
||||||
|
<Stat label="Open orders" value="3" onClick={() => go("my-orders")} />
|
||||||
|
<Stat label="Pending approval" value="1" />
|
||||||
|
<Stat label="Completed" value="28" />
|
||||||
|
<Stat label="Total spend YTD" value="₹4.6L" />
|
||||||
|
</div>
|
||||||
|
<Card title="Open orders" flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>PO Number</th><th>Title</th><th>Vessel</th><th>Status</th><th className="num">Amount</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ORDERS.filter(o => o.submitter === "Rajesh Pillai").slice(0, 4).map(o => (
|
||||||
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td>{o.title}</td>
|
||||||
|
<td className="muted">{o.vessel}</td>
|
||||||
|
<td><Badge status={o.status} /></td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isAuditor && (
|
||||||
|
<>
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
||||||
|
<Stat label="Total POs YTD" value="284" onClick={() => go("history")} />
|
||||||
|
<Stat label="Total spend YTD" value="₹98.4L" />
|
||||||
|
<Stat label="Avg PO value" value="₹34.6K" />
|
||||||
|
<Stat label="Vessels" value="5" />
|
||||||
|
</div>
|
||||||
|
<Card title="Quick access">
|
||||||
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
|
<button className="btn" onClick={() => go("history")}><Icon name="file" /> Order history</button>
|
||||||
|
<button className="btn" onClick={() => go("vendors")}><Icon name="users" /> Vendor registry</button>
|
||||||
|
<button className="btn"><Icon name="download" /> Export PDF</button>
|
||||||
|
<button className="btn"><Icon name="download" /> Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ═══════════════════ MY ORDERS ═══════════════════ */
|
||||||
|
const MyOrders = ({ go }) => {
|
||||||
|
const mine = ORDERS.filter(o => o.submitter === "Rajesh Pillai");
|
||||||
|
const open = mine.filter(o => ["DRAFT","SUBMITTED","MGR_REVIEW","VENDOR_ID_PENDING","EDITS_REQUESTED"].includes(o.status));
|
||||||
|
const past = mine.filter(o => !["DRAFT","SUBMITTED","MGR_REVIEW","VENDOR_ID_PENDING","EDITS_REQUESTED"].includes(o.status));
|
||||||
|
// Add a couple of synthetic past orders to make it feel real
|
||||||
|
const pastFull = [...past, ...ORDERS.filter(o => ["CLOSED","PAID_DELIVERED","SENT_FOR_PAYMENT","MGR_APPROVED","REJECTED"].includes(o.status)).slice(0,3)];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Purchase Orders", "My Orders"]} />
|
||||||
|
<h1 className="page-title">My Purchase Orders</h1>
|
||||||
|
<div className="page-sub">{open.length} open · {pastFull.length} past</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime" onClick={() => go("po-new")}><Icon name="plus" /> New PO</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="section-title">Open orders</h2>
|
||||||
|
<Card flush>
|
||||||
|
{open.length === 0 ? <div className="empty-state">No open orders.</div> : (
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>PO Number</th><th>Title</th><th>Vessel</th><th>Status</th><th>Updated</th><th className="num">Amount</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{open.map(o => (
|
||||||
|
<React.Fragment key={o.id}>
|
||||||
|
<tr className="clickable" onClick={() => go("po-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td>{o.title}</td>
|
||||||
|
<td className="muted">{o.vessel}</td>
|
||||||
|
<td><Badge status={o.status} /></td>
|
||||||
|
<td className="muted">{o.submitted || o.created || "—"}</td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
{o.managerNote && (
|
||||||
|
<tr><td colSpan="6" style={{ background: "oklch(98% 0.015 95)", borderTop: 0 }}>
|
||||||
|
<div style={{ display: "flex", gap: 10, alignItems: "flex-start", padding: "2px 0" }}>
|
||||||
|
<Badge status="EDITS_REQUESTED" />
|
||||||
|
<div style={{ fontSize: 12 }}>
|
||||||
|
<strong>Manager note · Anjali Krishnan:</strong> <span className="muted">{o.managerNote}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td></tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">Past orders</h2>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>PO Number</th><th>Title</th><th>Vessel</th><th>Status</th><th>Closed/Completed</th><th className="num">Amount</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{pastFull.map(o => (
|
||||||
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td>{o.title}</td>
|
||||||
|
<td className="muted">{o.vessel}</td>
|
||||||
|
<td><Badge status={o.status} /></td>
|
||||||
|
<td className="muted">{o.closed || o.paid || o.approved || o.rejected || "—"}</td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { LoginPage, Dashboard, MyOrders });
|
||||||
502
design_handoff_pelagia_portal/pages-2.jsx
Normal file
502
design_handoff_pelagia_portal/pages-2.jsx
Normal file
|
|
@ -0,0 +1,502 @@
|
||||||
|
/* Pelagia Portal — Approvals, PO Detail, New PO */
|
||||||
|
|
||||||
|
/* ═══════════════════ APPROVAL QUEUE ═══════════════════ */
|
||||||
|
const ApprovalsPage = ({ go }) => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Purchase Orders", "Approvals"]} />
|
||||||
|
<h1 className="page-title">Approval queue
|
||||||
|
<span style={{ color: "var(--muted)", fontWeight: 400, marginLeft: 10, fontSize: 16 }}>· {APPROVAL_QUEUE.length} pending</span>
|
||||||
|
</h1>
|
||||||
|
<div className="page-sub">POs awaiting manager decision · oldest first</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-bar">
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 6, padding: "0 10px", height: 28, background: "var(--surface)", border: "1px solid var(--line)", borderRadius: 6, flex: "0 0 240px" }}>
|
||||||
|
<Icon name="search" size={12} />
|
||||||
|
<input className="mono" style={{ border: 0, outline: 0, flex: 1, background: "transparent", fontSize: 11.5 }} placeholder="Search PO #, submitter, title" />
|
||||||
|
</div>
|
||||||
|
<select className="select"><option>All vessels</option>{VESSELS.map(v => <option key={v.id}>{v.name}</option>)}</select>
|
||||||
|
<select className="select"><option>Any submitter</option>{["Rajesh Pillai","Fatima Sheikh","Dev Shah"].map(s => <option key={s}>{s}</option>)}</select>
|
||||||
|
<input className="input" type="date" defaultValue="2026-05-01" style={{ width: 140 }} />
|
||||||
|
<span className="faint" style={{ fontSize: 11.5, marginLeft: "auto" }}>Sorted by submitted date</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>PO Number</th><th>Title</th><th>Submitter</th><th>Vessel</th><th>Submitted</th><th className="num">Amount</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{APPROVAL_QUEUE.map(o => (
|
||||||
|
<tr key={o.id} className="clickable" onClick={() => go("approval-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td>{o.title}</td>
|
||||||
|
<td>{o.submitter}</td>
|
||||||
|
<td className="muted">{o.vessel}</td>
|
||||||
|
<td className="muted">{o.submitted}</td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<span style={{ color: "var(--primary-ink)", fontSize: 12 }}>Review →</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ PO DETAIL (shared) ═══════════════════ */
|
||||||
|
const ItemsTable = ({ items }) => {
|
||||||
|
if (!items?.length) return <div className="empty-state">No line items.</div>;
|
||||||
|
const taxable = items.reduce((s, i) => s + i.qty * i.price, 0);
|
||||||
|
const gst = items.reduce((s, i) => s + i.qty * i.price * i.gst / 100, 0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th><th>Description</th><th className="num">Qty</th><th>Unit</th>
|
||||||
|
<th className="num">Unit Price</th><th className="num">GST</th><th className="num">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((i, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td>{i.name}</td>
|
||||||
|
<td className="muted">{i.desc}</td>
|
||||||
|
<td className="num">{i.qty}</td>
|
||||||
|
<td className="muted">{i.unit}</td>
|
||||||
|
<td className="num">{inrFull(i.price)}</td>
|
||||||
|
<td className="num muted">{i.gst}%</td>
|
||||||
|
<td className="num">{inrFull(i.qty * i.price * (1 + i.gst/100))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", padding: "12px 16px", gap: 32, borderTop: "1px solid var(--line)", background: "var(--paper-2)" }}>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>TAXABLE</div>
|
||||||
|
<div className="mono" style={{ fontSize: 14 }}>{inrFull(taxable)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>GST</div>
|
||||||
|
<div className="mono" style={{ fontSize: 14 }}>{inrFull(gst)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>GRAND TOTAL</div>
|
||||||
|
<div className="mono" style={{ fontSize: 16, fontWeight: 500 }}>{inrFull(taxable + gst)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PODetailBase = ({ order, go, role, isApproval }) => {
|
||||||
|
const isOwner = order.submitter === "Rajesh Pillai" && role === "TECHNICAL";
|
||||||
|
const isManager = role === "MANAGER" || role === "SUPERUSER";
|
||||||
|
const showEdit = ["DRAFT", "EDITS_REQUESTED"].includes(order.status) && (isOwner || role === "SUPERUSER");
|
||||||
|
const showReceipt = order.status === "PAID_DELIVERED" && (isOwner || role === "SUPERUSER");
|
||||||
|
const showVendorPicker = order.status === "VENDOR_ID_PENDING";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={[isApproval ? "Approvals" : "Purchase Orders", order.id]} />
|
||||||
|
<h1 className="page-title">{order.title}</h1>
|
||||||
|
<div className="page-sub">
|
||||||
|
Created by {order.submitter} · {order.created || order.submitted}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn"><Icon name="download" /> Export PDF</button>
|
||||||
|
{showEdit && <button className="btn"><Icon name="edit" /> Edit</button>}
|
||||||
|
{!isApproval && order.status === "DRAFT" && <button className="btn danger"><Icon name="trash" /> Discard</button>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-band">
|
||||||
|
<div className="detail-band-left">
|
||||||
|
<div className="po-id">{order.id}</div>
|
||||||
|
<Badge status={order.status} />
|
||||||
|
{order.project && <span className="role-badge">{order.project}</span>}
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: "right" }}>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>GRAND TOTAL</div>
|
||||||
|
<div className="mono" style={{ fontSize: 22, letterSpacing: "-0.01em" }}>{inr(order.amount)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order.managerNote && (
|
||||||
|
<div className="alert">
|
||||||
|
<Icon name="bell" />
|
||||||
|
<div>
|
||||||
|
<strong>Manager note · Anjali Krishnan:</strong> {order.managerNote}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="detail-layout">
|
||||||
|
{/* MAIN */}
|
||||||
|
<div>
|
||||||
|
{isApproval && (
|
||||||
|
<Card title="Manager actions" className="action-panel" style={{ marginBottom: 16 }}>
|
||||||
|
<ApprovalActions />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showVendorPicker && (
|
||||||
|
<Card title="Vendor selection required" style={{ marginBottom: 16 }}>
|
||||||
|
<div className="muted" style={{ marginBottom: 10, fontSize: 12.5 }}>
|
||||||
|
The manager has requested that a vendor be selected before approval.
|
||||||
|
</div>
|
||||||
|
<div className="form-row cols-2" style={{ alignItems: "end" }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Vendor</label>
|
||||||
|
<select className="select">
|
||||||
|
<option>Select vendor…</option>
|
||||||
|
{VENDORS.filter(v => v.active).map(v => <option key={v.id}>{v.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime">Submit & return to manager</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<h2 className="section-title">Summary</h2>
|
||||||
|
<Card>
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>Vessel</dt> <dd>{order.vessel}</dd>
|
||||||
|
<dt>Account</dt> <dd className="mono">{order.account}</dd>
|
||||||
|
<dt>Vendor</dt> <dd>{order.vendor || <em className="muted">Not yet assigned</em>}</dd>
|
||||||
|
<dt>Project code</dt> <dd>{order.project ? <span className="mono">{order.project}</span> : <em className="muted">—</em>}</dd>
|
||||||
|
<dt>Date required</dt> <dd>{order.required || "—"}</dd>
|
||||||
|
<dt>Currency</dt> <dd>INR (₹)</dd>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">Line items</h2>
|
||||||
|
<Card flush>
|
||||||
|
<ItemsTable items={order.items?.length ? order.items : ORDERS[0].items} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">Terms & conditions</h2>
|
||||||
|
<Card>
|
||||||
|
<dl className="kv">
|
||||||
|
<dt>Delivery</dt> <dd>{order.terms?.delivery || ORDERS[0].terms.delivery}</dd>
|
||||||
|
<dt>Dispatch</dt> <dd>{order.terms?.dispatch || ORDERS[0].terms.dispatch}</dd>
|
||||||
|
<dt>Inspection</dt> <dd>{order.terms?.inspection || ORDERS[0].terms.inspection}</dd>
|
||||||
|
<dt>Transit insurance</dt> <dd>{order.terms?.insurance || ORDERS[0].terms.insurance}</dd>
|
||||||
|
<dt>Payment terms</dt> <dd>{order.terms?.payment || ORDERS[0].terms.payment}</dd>
|
||||||
|
<dt>Other</dt> <dd>{order.terms?.others || ORDERS[0].terms.others}</dd>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">Documents</h2>
|
||||||
|
<Card>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{(order.docs || ORDERS[0].docs).map((d, i) => (
|
||||||
|
<div key={i} style={{ display: "flex", alignItems: "center", gap: 10, padding: "8px 10px", background: "var(--paper-2)", borderRadius: 6 }}>
|
||||||
|
<Icon name="paperclip" />
|
||||||
|
<span style={{ fontSize: 12.5 }}>{d.name}</span>
|
||||||
|
<span className="muted" style={{ fontSize: 11.5 }}>{d.size}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="btn sm"><Icon name="download" size={11} /> Download</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showReceipt && (
|
||||||
|
<>
|
||||||
|
<h2 className="section-title">Confirm receipt</h2>
|
||||||
|
<Card>
|
||||||
|
<div className="muted" style={{ marginBottom: 12, fontSize: 12.5 }}>
|
||||||
|
Goods have been marked as paid and dispatched. Upload the delivery receipt to close this PO.
|
||||||
|
</div>
|
||||||
|
<div className="form-row" style={{ gap: 10 }}>
|
||||||
|
<div style={{ padding: 22, border: "1.5px dashed var(--line-2)", borderRadius: 8, textAlign: "center", color: "var(--muted)" }}>
|
||||||
|
<Icon name="upload" /> Drop delivery receipt file or <span style={{ color: "var(--primary-ink)" }}>browse</span>
|
||||||
|
</div>
|
||||||
|
<textarea className="textarea" placeholder="Notes (optional) — note any damage or discrepancy"></textarea>
|
||||||
|
<button className="btn maritime" style={{ alignSelf: "flex-end" }}>Confirm receipt</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SIDEBAR */}
|
||||||
|
<div>
|
||||||
|
<Card title="Timestamps">
|
||||||
|
<dl className="kv" style={{ gridTemplateColumns: "100px 1fr", gap: "6px 8px", fontSize: 12 }}>
|
||||||
|
<dt>Created</dt> <dd className="mono">{order.created || "May 10, 2026"}</dd>
|
||||||
|
<dt>Submitted</dt> <dd className="mono">{order.submitted || "—"}</dd>
|
||||||
|
<dt>Approved</dt> <dd className="mono">{order.approved || "—"}</dd>
|
||||||
|
<dt>Paid</dt> <dd className="mono">{order.paid || "—"}</dd>
|
||||||
|
<dt>Closed</dt> <dd className="mono">{order.closed || "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ height: 14 }} />
|
||||||
|
|
||||||
|
<Card title="Audit trail">
|
||||||
|
<div>
|
||||||
|
{(order.audit || ORDERS[0].audit).map((a, i) => (
|
||||||
|
<div className="timeline-stop done" key={i}>
|
||||||
|
<div className="dot" />
|
||||||
|
<div>
|
||||||
|
<div className="actor">{a.who}</div>
|
||||||
|
<div className="action">{a.what}</div>
|
||||||
|
</div>
|
||||||
|
<div className="when">{a.when.split(" · ")[1] || a.when}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ApprovalActions = () => {
|
||||||
|
const [mode, setMode] = useS(null); // null | edits | reject | note | vendor
|
||||||
|
|
||||||
|
if (mode === null) {
|
||||||
|
return (
|
||||||
|
<div className="action-row">
|
||||||
|
<button className="btn maritime"><Icon name="check" /> Approve</button>
|
||||||
|
<button className="btn" onClick={() => setMode("note")}>Approve with Note</button>
|
||||||
|
<button className="btn" onClick={() => setMode("edits")}>Request Edits</button>
|
||||||
|
<button className="btn" onClick={() => setMode("vendor")}>Request Vendor ID</button>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="btn danger" onClick={() => setMode("reject")}>Reject</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
edits: ["Request edits", "Describe what needs to change before approval.", "var(--st-edits-fg)", "Send back to submitter"],
|
||||||
|
reject: ["Reject PO", "Provide a reason — this terminates the PO.", "var(--st-rejected-fg)", "Reject"],
|
||||||
|
note: ["Approve with note", "Optional note visible to the submitter.", "var(--primary-ink)", "Approve"],
|
||||||
|
vendor: ["Request vendor selection", "Note for submitter (optional). PO returns here once a vendor is chosen.", "var(--st-vendor-fg)", "Send back for vendor"],
|
||||||
|
};
|
||||||
|
const [title, sub, color, cta] = titles[mode];
|
||||||
|
return (
|
||||||
|
<div className="form-row" style={{ gap: 10 }}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 13 }}>{title}</div>
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>{sub}</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn sm" onClick={() => setMode(null)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
<textarea className="textarea" placeholder={mode === "vendor" ? "e.g. Please use the verified vendor in Kochi for faster delivery." : "Required reason / note…"}></textarea>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||||
|
<button className="btn" style={{ background: color, color: "var(--paper)", borderColor: color }}>{cta}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ═══════════════════ NEW PO ═══════════════════ */
|
||||||
|
const NewPOPage = ({ go }) => {
|
||||||
|
const [rows, setRows] = useS([
|
||||||
|
{ name: "Marine Bearing 6310-2RS", desc: "50×110×27mm, sealed", qty: 24, unit: "ea", size: "", price: 4250, gst: 18, suggest: false },
|
||||||
|
{ name: "Cylinder Head Gasket Set", desc: "MAN B&W L28/32H", qty: 4, unit: "set", size: "", price: 18750, gst: 18, suggest: false },
|
||||||
|
]);
|
||||||
|
const [suggestIdx, setSuggestIdx] = useS(-1);
|
||||||
|
|
||||||
|
const taxable = rows.reduce((s, r) => s + (Number(r.qty) || 0) * (Number(r.price) || 0), 0);
|
||||||
|
const gst = rows.reduce((s, r) => s + (Number(r.qty) || 0) * (Number(r.price) || 0) * (Number(r.gst) || 0) / 100, 0);
|
||||||
|
|
||||||
|
const addRow = () => setRows([...rows, { name: "", desc: "", qty: 1, unit: "ea", size: "", price: 0, gst: 18 }]);
|
||||||
|
const updateRow = (idx, key, val) => setRows(rows.map((r, i) => i === idx ? { ...r, [key]: val } : r));
|
||||||
|
const removeRow = (idx) => setRows(rows.filter((_, i) => i !== idx));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Purchase Orders", "New PO"]} />
|
||||||
|
<h1 className="page-title">New purchase order</h1>
|
||||||
|
<div className="page-sub">Fill the four sections below. You can save as draft at any time.</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn" onClick={() => go("my-orders")}>Cancel</button>
|
||||||
|
<button className="btn">Save as Draft</button>
|
||||||
|
<button className="btn maritime"><Icon name="check" /> Submit for Approval</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="section-title">1 · Header</h2>
|
||||||
|
<Card>
|
||||||
|
<div className="form-row" style={{ gap: 14 }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Title <span className="req">*</span></label>
|
||||||
|
<input className="input" defaultValue="Engine room bearings — Q2 replenishment" />
|
||||||
|
</div>
|
||||||
|
<div className="form-row cols-3">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Vessel <span className="req">*</span></label>
|
||||||
|
<select className="select"><option>MV Pelagia Voyager</option>{VESSELS.slice(1).map(v => <option key={v.id}>{v.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Account <span className="req">*</span></label>
|
||||||
|
<select className="select"><option>ENGINE — Engine Department</option>{ACCOUNTS.slice(0,4).map(a => <option key={a.id}>{a.code} — {a.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Vendor (optional)</label>
|
||||||
|
<select className="select"><option>Mahalakshmi Marine Stores</option>{VENDORS.slice(1).map(v => <option key={v.id}>{v.name}</option>)}<option>+ Add new vendor…</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row cols-3">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Date required</label>
|
||||||
|
<input className="input" type="date" defaultValue="2026-05-28" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Project code</label>
|
||||||
|
<input className="input mono" defaultValue="ER-MAINT-2026" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Currency</label>
|
||||||
|
<select className="select"><option>INR (₹)</option><option>USD ($)</option><option>EUR (€)</option></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Description / remarks</label>
|
||||||
|
<textarea className="textarea" defaultValue="Second-quarter replenishment of bearings and gasket sets for the engine room. Includes spares for the L28/32H mains and auxiliaries."></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">2 · Line items</h2>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: "26%" }}>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th className="num" style={{ width: 70 }}>Qty</th>
|
||||||
|
<th style={{ width: 60 }}>Unit</th>
|
||||||
|
<th className="num" style={{ width: 110 }}>Unit price</th>
|
||||||
|
<th className="num" style={{ width: 70 }}>GST %</th>
|
||||||
|
<th className="num" style={{ width: 110 }}>Total</th>
|
||||||
|
<th style={{ width: 30 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, idx) => (
|
||||||
|
<React.Fragment key={idx}>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input className="input" style={{ height: 28, fontSize: 12.5 }} value={r.name}
|
||||||
|
onChange={e => updateRow(idx, "name", e.target.value)}
|
||||||
|
onFocus={() => setSuggestIdx(idx)}
|
||||||
|
onBlur={() => setTimeout(() => setSuggestIdx(-1), 200)} />
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input className="input" style={{ height: 28, fontSize: 12.5 }} value={r.desc}
|
||||||
|
onChange={e => updateRow(idx, "desc", e.target.value)} />
|
||||||
|
</td>
|
||||||
|
<td><input className="input num mono" style={{ height: 28, fontSize: 12.5, textAlign: "right" }} value={r.qty} onChange={e => updateRow(idx, "qty", e.target.value)} /></td>
|
||||||
|
<td><input className="input" style={{ height: 28, fontSize: 12.5 }} value={r.unit} onChange={e => updateRow(idx, "unit", e.target.value)} /></td>
|
||||||
|
<td><input className="input mono" style={{ height: 28, fontSize: 12.5, textAlign: "right" }} value={r.price} onChange={e => updateRow(idx, "price", e.target.value)} /></td>
|
||||||
|
<td><input className="input mono" style={{ height: 28, fontSize: 12.5, textAlign: "right" }} value={r.gst} onChange={e => updateRow(idx, "gst", e.target.value)} /></td>
|
||||||
|
<td className="num mono">{inr((Number(r.qty)||0) * (Number(r.price)||0) * (1 + (Number(r.gst)||0)/100))}</td>
|
||||||
|
<td><button className="btn icon sm" onClick={() => removeRow(idx)} title="Remove"><Icon name="trash" size={11} /></button></td>
|
||||||
|
</tr>
|
||||||
|
{suggestIdx === idx && r.name && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="8" style={{ padding: "0 16px 8px", background: "var(--paper-2)" }}>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--muted)", padding: "6px 0" }}>
|
||||||
|
<strong style={{ color: "var(--ink)" }}>Last seen at:</strong>
|
||||||
|
<span className="dot-sep">·</span> Mahalakshmi Marine Stores <span className="mono">₹4,250</span>
|
||||||
|
<span className="dot-sep">·</span> Coastline Engineering <span className="mono">₹4,380</span>
|
||||||
|
<span className="dot-sep">·</span> Konark Industrial Spares <span className="mono">₹4,520</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div style={{ padding: 12, borderTop: "1px solid var(--line)", display: "flex", gap: 12, alignItems: "center" }}>
|
||||||
|
<button className="btn sm" onClick={addRow}><Icon name="plus" size={11} /> Add line item</button>
|
||||||
|
<span className="faint" style={{ fontSize: 11.5 }}>Tip: start typing a product name to see vendor price hints</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", padding: "12px 16px", gap: 32, borderTop: "1px solid var(--line)", background: "var(--paper-2)" }}>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>TAXABLE</div>
|
||||||
|
<div className="mono" style={{ fontSize: 14 }}>{inrFull(taxable)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>GST</div>
|
||||||
|
<div className="mono" style={{ fontSize: 14 }}>{inrFull(gst)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="muted" style={{ fontSize: 11 }}>GRAND TOTAL</div>
|
||||||
|
<div className="mono" style={{ fontSize: 17, fontWeight: 500 }}>{inrFull(taxable + gst)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">3 · Terms & conditions</h2>
|
||||||
|
<Card>
|
||||||
|
<div className="form-row cols-2" style={{ gap: 14 }}>
|
||||||
|
{[
|
||||||
|
["Delivery", "FOB Cochin Port. Vendor responsible until cargo cleared at gate."],
|
||||||
|
["Dispatch", "Within 14 days of approval."],
|
||||||
|
["Inspection", "Joint inspection at vendor warehouse prior to dispatch."],
|
||||||
|
["Transit insurance", "Transit insurance included in vendor scope."],
|
||||||
|
["Payment terms", "30 days from receipt of goods and invoice."],
|
||||||
|
["Other", "All items to be packed with desiccant and shrink-wrapped."],
|
||||||
|
].map(([k, v]) => (
|
||||||
|
<div className="field" key={k}>
|
||||||
|
<label className="field-label">{k}</label>
|
||||||
|
<textarea className="textarea" style={{ minHeight: 56 }} defaultValue={v}></textarea>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">4 · Documents</h2>
|
||||||
|
<Card>
|
||||||
|
<div style={{ padding: 22, border: "1.5px dashed var(--line-2)", borderRadius: 8, textAlign: "center", color: "var(--muted)" }}>
|
||||||
|
<Icon name="upload" /> Drop files here or <span style={{ color: "var(--primary-ink)" }}>browse</span>
|
||||||
|
<div className="faint" style={{ fontSize: 11.5, marginTop: 4 }}>Quotations, technical sheets, requisitions. Max 25 MB per file.</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 12, display: "flex", flexDirection: "column", gap: 6 }}>
|
||||||
|
{[
|
||||||
|
["Vendor Quotation Q-2841.pdf", "412 KB"],
|
||||||
|
["Engine Inspection Report.pdf", "1.8 MB"],
|
||||||
|
].map(([n, s]) => (
|
||||||
|
<div key={n} style={{ display: "flex", alignItems: "center", gap: 10, padding: "7px 10px", background: "var(--paper-2)", borderRadius: 6, fontSize: 12.5 }}>
|
||||||
|
<Icon name="paperclip" />
|
||||||
|
<span>{n}</span>
|
||||||
|
<span className="muted" style={{ fontSize: 11.5 }}>{s}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<button className="btn sm icon"><Icon name="trash" size={11} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 22, display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
<button className="btn">Save as Draft</button>
|
||||||
|
<button className="btn maritime"><Icon name="check" /> Submit for Approval</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { ApprovalsPage, PODetailBase, NewPOPage });
|
||||||
490
design_handoff_pelagia_portal/pages-3.jsx
Normal file
490
design_handoff_pelagia_portal/pages-3.jsx
Normal file
|
|
@ -0,0 +1,490 @@
|
||||||
|
/* Pelagia Portal — Payments, History, Vendors, Items, Sites, Cart, Users, Vessels, Accounts */
|
||||||
|
|
||||||
|
/* ═══════════════════ PAYMENTS ═══════════════════ */
|
||||||
|
const PaymentsPage = ({ go }) => {
|
||||||
|
const ready = ORDERS.filter(o => o.status === "MGR_APPROVED");
|
||||||
|
const sent = ORDERS.filter(o => o.status === "SENT_FOR_PAYMENT");
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Purchase Orders", "Payments"]} />
|
||||||
|
<h1 className="page-title">Payment queue</h1>
|
||||||
|
<div className="page-sub">{ready.length} ready for payment · {sent.length} awaiting confirmation</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="section-title">Ready for payment</h2>
|
||||||
|
<div className="pay-grid">
|
||||||
|
{ready.map(o => (
|
||||||
|
<div className="pay-card" key={o.id}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<div className="po-num">{o.id}</div>
|
||||||
|
<div style={{ marginTop: 2, fontSize: 13.5, fontWeight: 500 }}>{o.title}</div>
|
||||||
|
</div>
|
||||||
|
<Badge status={o.status} />
|
||||||
|
</div>
|
||||||
|
<dl className="kv" style={{ gridTemplateColumns: "90px 1fr", gap: "4px 8px", fontSize: 12 }}>
|
||||||
|
<dt>Vessel</dt> <dd>{o.vessel}</dd>
|
||||||
|
<dt>Vendor</dt> <dd>{o.vendor || <em className="muted">—</em>}</dd>
|
||||||
|
<dt>Submitter</dt> <dd>{o.submitter}</dd>
|
||||||
|
<dt>Approved</dt> <dd className="mono">{o.approved || "—"}</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="amount">{inrFull(o.amount)}</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn" style={{ flex: 1 }} onClick={() => go("po-detail", o.id)}><Icon name="eye" /> View</button>
|
||||||
|
<button className="btn maritime" style={{ flex: 1.6, justifyContent: "center" }}><Icon name="arrowRight" /> Send for Payment</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="section-title">Processing — awaiting confirmation</h2>
|
||||||
|
<div className="pay-grid">
|
||||||
|
{sent.map(o => (
|
||||||
|
<div className="pay-card" key={o.id}>
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
|
<div>
|
||||||
|
<div className="po-num">{o.id}</div>
|
||||||
|
<div style={{ marginTop: 2, fontSize: 13.5, fontWeight: 500 }}>{o.title}</div>
|
||||||
|
</div>
|
||||||
|
<Badge status={o.status} />
|
||||||
|
</div>
|
||||||
|
<dl className="kv" style={{ gridTemplateColumns: "90px 1fr", gap: "4px 8px", fontSize: 12 }}>
|
||||||
|
<dt>Vessel</dt> <dd>{o.vessel}</dd>
|
||||||
|
<dt>Vendor</dt> <dd>{o.vendor}</dd>
|
||||||
|
<dt>Submitter</dt> <dd>{o.submitter}</dd>
|
||||||
|
<dt>Sent on</dt> <dd className="mono">May 09, 2026</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="amount">{inrFull(o.amount)}</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn" style={{ flex: 1 }} onClick={() => go("po-detail", o.id)}><Icon name="eye" /> View</button>
|
||||||
|
<button className="btn maritime" style={{ flex: 1.6, justifyContent: "center" }}><Icon name="check" /> Mark as Paid</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sent.length === 0 && <div className="empty-state" style={{ gridColumn: "1 / -1" }}>Nothing currently processing.</div>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ═══════════════════ HISTORY ═══════════════════ */
|
||||||
|
const HistoryPage = ({ go }) => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Purchase Orders", "History"]} />
|
||||||
|
<h1 className="page-title">Order history</h1>
|
||||||
|
<div className="page-sub">All POs across all statuses · apply filters then export</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn"><Icon name="download" /> Export PDF</button>
|
||||||
|
<button className="btn"><Icon name="download" /> Export CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="filter-bar">
|
||||||
|
<div className="field" style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
<span className="muted" style={{ fontSize: 11.5 }}>From</span>
|
||||||
|
<input className="input" type="date" defaultValue="2026-04-01" style={{ width: 130 }} />
|
||||||
|
</div>
|
||||||
|
<div className="field" style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
||||||
|
<span className="muted" style={{ fontSize: 11.5 }}>To</span>
|
||||||
|
<input className="input" type="date" defaultValue="2026-05-14" style={{ width: 130 }} />
|
||||||
|
</div>
|
||||||
|
<select className="select"><option>All vessels</option>{VESSELS.map(v => <option key={v.id}>{v.name}</option>)}</select>
|
||||||
|
<select className="select">
|
||||||
|
<option>All statuses</option>
|
||||||
|
{Object.keys(STATUS_LABELS).map(s => <option key={s}>{STATUS_LABELS[s][1]}</option>)}
|
||||||
|
</select>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<span className="faint" style={{ fontSize: 11.5 }}>{ORDERS.length} matching · export uses current filters</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>PO Number</th><th>Title</th><th>Vessel</th><th>Submitter</th>
|
||||||
|
<th>Status</th><th>Created</th><th className="num">Amount</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ORDERS.map(o => (
|
||||||
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td>{o.title}</td>
|
||||||
|
<td className="muted">{o.vessel}</td>
|
||||||
|
<td className="muted">{o.submitter}</td>
|
||||||
|
<td><Badge status={o.status} /></td>
|
||||||
|
<td className="muted">{o.submitted || o.created || "—"}</td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ VENDOR REGISTRY ═══════════════════ */
|
||||||
|
const VendorsPage = ({ go }) => {
|
||||||
|
const [showAdd, setShowAdd] = useS(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Vendors"]} />
|
||||||
|
<h1 className="page-title">Vendor registry</h1>
|
||||||
|
<div className="page-sub">{VENDORS.length} vendors · {VENDORS.filter(v => v.verified).length} verified via GSTIN lookup</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime" onClick={() => setShowAdd(true)}><Icon name="plus" /> Add vendor</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showAdd && <AddVendorPanel onClose={() => setShowAdd(false)} />}
|
||||||
|
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Vendor ID</th><th>Name</th><th>Contact</th>
|
||||||
|
<th className="num">Items</th><th>Verified</th><th>Status</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{VENDORS.map(v => (
|
||||||
|
<tr key={v.id} className="clickable" onClick={() => go("vendor-detail", v.id)}>
|
||||||
|
<td className="mono" style={{ fontSize: 12 }}>{v.vid || <span className="badge vendor no-dot">Pending</span>}</td>
|
||||||
|
<td>
|
||||||
|
<div style={{ fontWeight: 500 }}>{v.name}</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11.5 }}>{v.city} · GSTIN <span className="mono">{v.gstin}</span></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div>{v.contact}</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11.5 }}>{v.email}</div>
|
||||||
|
</td>
|
||||||
|
<td className="num mono">{[42, 18, 64, 7, 22, 31][VENDORS.indexOf(v)]}</td>
|
||||||
|
<td>{v.verified
|
||||||
|
? <span className="verified-mark"><Icon name="check" size={11} /> Verified</span>
|
||||||
|
: <span className="muted" style={{ fontSize: 11.5 }}>—</span>}
|
||||||
|
</td>
|
||||||
|
<td><Badge className={v.active ? "closed" : "draft"} noDot>{v.active ? "Active" : "Inactive"}</Badge></td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<button className="btn sm icon" onClick={e => e.stopPropagation()}><Icon name="edit" size={11} /></button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AddVendorPanel = ({ onClose }) => {
|
||||||
|
const [step, setStep] = useS(1); // 1 type GSTIN, 2 captcha, 3 verified
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Add vendor" action={<button className="btn sm" onClick={onClose}>Close</button>} className="action-panel" style={{ marginBottom: 18 }}>
|
||||||
|
<div className="form-row" style={{ gap: 14 }}>
|
||||||
|
<div className="alert info" style={{ margin: 0 }}>
|
||||||
|
<Icon name="search" />
|
||||||
|
<div>Look up the vendor's GSTIN to auto-fill name, address, and pincode. Manual entry is allowed if needed.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row cols-3" style={{ alignItems: "end" }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">GSTIN <span className="req">*</span></label>
|
||||||
|
<input className="input mono" placeholder="15-character GSTIN" defaultValue="29ABCDE1234F1Z5" />
|
||||||
|
</div>
|
||||||
|
{step === 1 && (
|
||||||
|
<button className="btn maritime" onClick={() => setStep(2)} style={{ height: 32 }}>
|
||||||
|
<Icon name="search" /> Look up
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{step >= 2 && (
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Captcha · type code shown</label>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<div className="mono" style={{
|
||||||
|
flex: "0 0 110px", height: 32,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
background: "repeating-linear-gradient(45deg, var(--paper-2) 0 6px, var(--surface) 6px 12px)",
|
||||||
|
border: "1px solid var(--line)", borderRadius: 6,
|
||||||
|
fontWeight: 600, letterSpacing: "0.15em", fontSize: 15,
|
||||||
|
textDecoration: "line-through wavy var(--faint) 1px"
|
||||||
|
}}>K4G7AP</div>
|
||||||
|
<input className="input mono" placeholder="Enter code" style={{ flex: 1 }} />
|
||||||
|
<button className="btn icon" title="Refresh captcha"><Icon name="refresh" size={12} /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step >= 2 && (
|
||||||
|
<button className="btn maritime" onClick={() => setStep(3)} style={{ height: 32 }}>
|
||||||
|
<Icon name="check" /> Verify
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{step === 3 && (
|
||||||
|
<>
|
||||||
|
<div className="alert" style={{ background: "oklch(96% 0.04 150)", borderColor: "var(--st-closed-bg)", color: "var(--st-closed-fg)" }}>
|
||||||
|
<Icon name="check" />
|
||||||
|
<div><strong>Verified.</strong> Legal name, address, and pincode have been pulled from the GST portal.</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-row cols-2">
|
||||||
|
<div className="field"><label className="field-label">Legal name</label><input className="input" defaultValue="Mahalakshmi Marine Stores Pvt Ltd" /></div>
|
||||||
|
<div className="field"><label className="field-label">Trade name</label><input className="input" defaultValue="Mahalakshmi Marine Stores" /></div>
|
||||||
|
<div className="field" style={{ gridColumn: "1 / -1" }}><label className="field-label">Registered address</label><textarea className="textarea" defaultValue="48/2A Bristow Road, Willingdon Island, Kochi, Kerala — 682003"></textarea></div>
|
||||||
|
<div className="field"><label className="field-label">Pincode</label><input className="input mono" defaultValue="682003" /></div>
|
||||||
|
<div className="field"><label className="field-label">Geocoded location</label><input className="input mono" defaultValue="9.9489° N · 76.2622° E" readOnly /></div>
|
||||||
|
<div className="field"><label className="field-label">Contact name</label><input className="input" defaultValue="R. Subramanian" /></div>
|
||||||
|
<div className="field"><label className="field-label">Email</label><input className="input" defaultValue="sales@mahalakshmimarine.in" /></div>
|
||||||
|
<div className="field"><label className="field-label">Mobile</label><input className="input mono" defaultValue="+91 98450 12011" /></div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
<button className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
<button className="btn maritime" onClick={onClose}>Save vendor</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ═══════════════════ VENDOR DETAIL ═══════════════════ */
|
||||||
|
const VendorDetailPage = ({ go, id }) => {
|
||||||
|
const v = VENDORS.find(x => x.id === id) || VENDORS[0];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Vendors", v.name]} />
|
||||||
|
<h1 className="page-title">{v.name}</h1>
|
||||||
|
<div className="page-sub">
|
||||||
|
{v.vid ? <><span className="mono">{v.vid}</span><span className="dot-sep">·</span></> : null}
|
||||||
|
{v.verified && <><span className="verified-mark"><Icon name="check" size={11} /> GSTIN verified</span><span className="dot-sep">·</span></>}
|
||||||
|
<Badge className={v.active ? "closed" : "draft"} noDot>{v.active ? "Active" : "Inactive"}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn"><Icon name="edit" /> Edit vendor</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="detail-layout">
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Items supplied</h2>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Code</th><th>Product</th><th className="num">Last quoted</th><th>Last updated</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{PRODUCTS.slice(0, 5).map(p => (
|
||||||
|
<tr key={p.id} className="clickable" onClick={() => go("item-detail", p.id)}>
|
||||||
|
<td className="mono" style={{ fontSize: 12 }}>{p.code}</td>
|
||||||
|
<td>{p.name}</td>
|
||||||
|
<td className="num mono">{inrFull(p.lastPrice)}</td>
|
||||||
|
<td className="muted">{p.updated}</td>
|
||||||
|
<td style={{ textAlign: "right" }}><Icon name="arrowRight" size={11} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">Recent purchase orders</h2>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>PO Number</th><th>Status</th><th>Vessel</th><th>Created</th><th className="num">Amount</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ORDERS.slice(0, 6).map(o => (
|
||||||
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td><Badge status={o.status} /></td>
|
||||||
|
<td className="muted">{o.vessel}</td>
|
||||||
|
<td className="muted">{o.submitted || "—"}</td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Card title="Vendor info">
|
||||||
|
<dl className="kv" style={{ gridTemplateColumns: "90px 1fr", gap: "8px 8px", fontSize: 12 }}>
|
||||||
|
<dt>GSTIN</dt> <dd className="mono">{v.gstin}</dd>
|
||||||
|
<dt>Pincode</dt> <dd className="mono">{v.pin}</dd>
|
||||||
|
<dt>City</dt> <dd>{v.city}</dd>
|
||||||
|
<dt>Contact</dt> <dd>{v.contact}</dd>
|
||||||
|
<dt>Mobile</dt> <dd className="mono">{v.phone}</dd>
|
||||||
|
<dt>Email</dt> <dd style={{ wordBreak: "break-all" }}>{v.email}</dd>
|
||||||
|
</dl>
|
||||||
|
</Card>
|
||||||
|
<div style={{ height: 14 }} />
|
||||||
|
<Card title="Address">
|
||||||
|
<div style={{ fontSize: 12.5 }}>
|
||||||
|
48/2A Bristow Road, Willingdon Island,<br />
|
||||||
|
Kochi, Kerala — {v.pin}
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, height: 110, background: "repeating-linear-gradient(135deg, var(--paper-2) 0 10px, var(--surface) 10px 20px)", border: "1px solid var(--line)", borderRadius: 6, position: "relative" }}>
|
||||||
|
<div style={{ position: "absolute", left: "40%", top: "45%", width: 12, height: 12, borderRadius: "50%", background: "var(--primary)", border: "2px solid var(--surface)" }} />
|
||||||
|
<div style={{ position: "absolute", bottom: 6, left: 8, fontSize: 10.5, color: "var(--muted)", fontFamily: "var(--font-mono)" }}>9.9489° N · 76.2622° E</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ═══════════════════ ITEMS CATALOGUE ═══════════════════ */
|
||||||
|
const ItemsPage = ({ go }) => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Items"]} />
|
||||||
|
<h1 className="page-title">Item catalogue</h1>
|
||||||
|
<div className="page-sub">{PRODUCTS.length} products · auto-synced when POs are marked as paid</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime"><Icon name="plus" /> Add product</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="alert info">
|
||||||
|
<Icon name="bell" />
|
||||||
|
<div>Items are added automatically when a PO is marked as paid. Manual entry is reserved for ADMIN.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Code</th><th>Name</th><th>Description</th>
|
||||||
|
<th className="num">Vendors</th><th className="num">Last price</th><th>Last vendor</th><th>Updated</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{PRODUCTS.map(p => (
|
||||||
|
<tr key={p.id} className="clickable" onClick={() => go("item-detail", p.id)}>
|
||||||
|
<td className="mono" style={{ fontSize: 12 }}>{p.code}</td>
|
||||||
|
<td style={{ fontWeight: 500 }}>{p.name}</td>
|
||||||
|
<td className="muted">{p.desc}</td>
|
||||||
|
<td className="num mono">{p.vendors}</td>
|
||||||
|
<td className="num mono">{inr(p.lastPrice)}</td>
|
||||||
|
<td className="muted">{p.lastVendor}</td>
|
||||||
|
<td className="muted">{p.updated}</td>
|
||||||
|
<td><Badge className={p.active ? "closed" : "draft"} noDot>{p.active ? "Active" : "Inactive"}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ ITEM DETAIL ═══════════════════ */
|
||||||
|
const ItemDetailPage = ({ go, id }) => {
|
||||||
|
const p = PRODUCTS.find(x => x.id === id) || PRODUCTS[0];
|
||||||
|
const [siteFilter, setSiteFilter] = useS("");
|
||||||
|
const vendors = [...ITEM_VENDORS].sort((a, b) => siteFilter ? a.distance - b.distance : 0);
|
||||||
|
const max = Math.max(...vendors.map(v => v.price));
|
||||||
|
const min = Math.min(...vendors.map(v => v.price));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Items", p.name]} />
|
||||||
|
<h1 className="page-title">{p.name}</h1>
|
||||||
|
<div className="page-sub">
|
||||||
|
<span className="mono">{p.code}</span><span className="dot-sep">·</span>
|
||||||
|
<Badge className="closed" noDot>Active</Badge><span className="dot-sep">·</span>
|
||||||
|
<span>{p.desc}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn"><Icon name="cart" /> Add to Cart</button>
|
||||||
|
<button className="btn"><Icon name="settings" /> Toggle Active</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
||||||
|
<Stat label="Vendors supplying" value={vendors.length} />
|
||||||
|
<Stat label="Lowest price" value={inr(min)} sub={<span className="muted">Anchor Supply Traders</span>} />
|
||||||
|
<Stat label="Highest price" value={inr(max)} sub={<span className="muted">Konark Industrial Spares</span>} />
|
||||||
|
<Stat label="Sites with stock" value="3" sub={<span className="muted">of 4 sites</span>} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Card title="Price comparison" action={<span className="muted" style={{ fontSize: 11.5 }}>Unit price · INR</span>}>
|
||||||
|
<div className="bar-chart" style={{ height: 130, marginBottom: 24 }}>
|
||||||
|
{vendors.map((v, i) => (
|
||||||
|
<div className="bar" key={i}
|
||||||
|
data-label={v.vendor.split(" ")[0]}
|
||||||
|
style={{ height: (v.price / max * 120) + "px", background: v.price === min ? "var(--primary)" : "var(--primary-soft)" }}>
|
||||||
|
<span className="bar-val">{inr(v.price)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Stock by site">
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
|
||||||
|
<span className="tag-chip has-link">Cochin Port Depot <span className="mono" style={{ color: "var(--muted)" }}>· 18 ea</span></span>
|
||||||
|
<span className="tag-chip has-link">Chennai South Dock <span className="mono" style={{ color: "var(--muted)" }}>· 6 ea</span></span>
|
||||||
|
<span className="tag-chip has-link">Visakhapatnam Yard <span className="mono" style={{ color: "var(--muted)" }}>· 14 ea</span></span>
|
||||||
|
<span className="tag-chip" style={{ color: "var(--muted)" }}>Mumbai BPX Office <span className="mono">· 0 ea</span></span>
|
||||||
|
</div>
|
||||||
|
<div className="divider" />
|
||||||
|
<div className="muted" style={{ fontSize: 12 }}>Inventory is updated when consumption is logged at the site. Click a chip to open site detail.</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 10 }}>
|
||||||
|
<h2 className="section-title" style={{ margin: 0 }}>Vendor pricing</h2>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<span className="muted" style={{ fontSize: 11.5 }}>Sort by distance from</span>
|
||||||
|
<select className="select" style={{ height: 28, fontSize: 12 }} value={siteFilter} onChange={e => setSiteFilter(e.target.value)}>
|
||||||
|
<option value="">— Any site —</option>
|
||||||
|
{SITES.map(s => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Vendor</th><th>Verified</th>
|
||||||
|
<th className="num">Unit price</th>
|
||||||
|
{siteFilter && <th className="num">Distance</th>}
|
||||||
|
<th>Last updated</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{vendors.map((v, i) => (
|
||||||
|
<tr key={i}>
|
||||||
|
<td>
|
||||||
|
{v.closest && siteFilter && <span style={{ color: "oklch(60% 0.15 70)", marginRight: 4 }}>★</span>}
|
||||||
|
<span className="has-link" style={{ color: "var(--primary-ink)", cursor: "pointer" }}>{v.vendor}</span>
|
||||||
|
</td>
|
||||||
|
<td>{v.verified ? <span className="verified-mark"><Icon name="check" size={11} /> Verified</span> : <span className="muted">—</span>}</td>
|
||||||
|
<td className="num mono">{inrFull(v.price)}</td>
|
||||||
|
{siteFilter && <td className="num mono">{v.distance} km</td>}
|
||||||
|
<td className="muted">{v.updated}</td>
|
||||||
|
<td style={{ textAlign: "right" }}>
|
||||||
|
<button className="btn sm"><Icon name="cart" size={11} /> Add to Cart</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
Object.assign(window, { PaymentsPage, HistoryPage, VendorsPage, VendorDetailPage, ItemsPage, ItemDetailPage });
|
||||||
451
design_handoff_pelagia_portal/pages-4.jsx
Normal file
451
design_handoff_pelagia_portal/pages-4.jsx
Normal file
|
|
@ -0,0 +1,451 @@
|
||||||
|
/* Pelagia Portal — Sites, Cart, Vessels, Accounts, Users, Import PO */
|
||||||
|
|
||||||
|
/* ═══════════════════ SITES ═══════════════════ */
|
||||||
|
const SitesPage = ({ go }) => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Sites"]} />
|
||||||
|
<h1 className="page-title">Sites</h1>
|
||||||
|
<div className="page-sub">{SITES.length} ports, depots, and offices that hold inventory</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime"><Icon name="plus" /> Add site</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th><th>Code</th><th>Address</th>
|
||||||
|
<th className="num">Vessels</th><th className="num">Items</th>
|
||||||
|
<th>Location</th><th>Status</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{SITES.map(s => (
|
||||||
|
<tr key={s.id} className="clickable" onClick={() => go("site-detail", s.id)}>
|
||||||
|
<td style={{ fontWeight: 500 }}>{s.name}</td>
|
||||||
|
<td className="mono" style={{ fontSize: 12 }}>{s.code}</td>
|
||||||
|
<td className="muted">{s.address}</td>
|
||||||
|
<td className="num mono">{s.vessels}</td>
|
||||||
|
<td className="num mono">{s.items}</td>
|
||||||
|
<td className="mono" style={{ fontSize: 11.5 }}>{["9.95° N","19.04° N","13.07° N","17.69° N"][SITES.indexOf(s)]} · {["76.26° E","72.85° E","80.26° E","83.21° E"][SITES.indexOf(s)]}</td>
|
||||||
|
<td><Badge className="closed" noDot>Active</Badge></td>
|
||||||
|
<td style={{ textAlign: "right" }}><button className="btn sm icon" onClick={e => e.stopPropagation()}><Icon name="edit" size={11} /></button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ SITE DETAIL ═══════════════════ */
|
||||||
|
const SiteDetailPage = ({ go, id }) => {
|
||||||
|
const s = SITES.find(x => x.id === id) || SITES[0];
|
||||||
|
const max = Math.max(...SITE_INVENTORY.map(i => i.qty));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Sites", s.name]} />
|
||||||
|
<h1 className="page-title">{s.name}</h1>
|
||||||
|
<div className="page-sub">
|
||||||
|
<span className="mono">{s.code}</span><span className="dot-sep">·</span>
|
||||||
|
{s.address}<span className="dot-sep">·</span>
|
||||||
|
<span className="mono">9.9489° N · 76.2622° E</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn"><Icon name="edit" /> Edit site</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stat-grid" style={{ marginBottom: 22 }}>
|
||||||
|
<Stat label="Vessels at site" value={s.vessels} />
|
||||||
|
<Stat label="Items tracked" value={s.items} />
|
||||||
|
<Stat label="Inventory value" value="₹18.6L" sub={<span className="muted">last calculated 12:30</span>} />
|
||||||
|
<Stat label="Consumption · 30d" value="412 ea" sub={<span className="muted">across all items</span>} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Card title="Current stock" action={<span className="muted" style={{ fontSize: 11.5 }}>Quantity on hand</span>}>
|
||||||
|
<div style={{ paddingTop: 4 }}>
|
||||||
|
{SITE_INVENTORY.map((it, i) => (
|
||||||
|
<div className="hbar-row" key={i}>
|
||||||
|
<div className="name">{it.name}</div>
|
||||||
|
<div className="track"><div className="fill" style={{ width: (it.qty / max * 100) + "%" }} /></div>
|
||||||
|
<div className="v">{it.qty} ea</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Consumption · last 12 days" action={<span className="muted" style={{ fontSize: 11.5 }}>Daily draw-down</span>}>
|
||||||
|
<div style={{ height: 160, position: "relative", paddingTop: 4 }}>
|
||||||
|
<svg viewBox="0 0 360 140" width="100%" height="140" preserveAspectRatio="none">
|
||||||
|
{/* grid lines */}
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<line key={i} x1="0" x2="360" y1={20 + i * 30} y2={20 + i * 30}
|
||||||
|
stroke="var(--line)" strokeWidth="1" strokeDasharray="2 3" />
|
||||||
|
))}
|
||||||
|
{[0, 1, 2, 3, 4].map(seriesIdx => {
|
||||||
|
const stroke = ["var(--primary)", "oklch(55% 0.09 30)", "oklch(50% 0.06 150)", "oklch(50% 0.07 280)", "oklch(55% 0.08 60)"][seriesIdx];
|
||||||
|
const pts = CONSUMPTION.map((d, i) => {
|
||||||
|
const x = (i / (CONSUMPTION.length - 1)) * 350 + 5;
|
||||||
|
const y = 130 - (d.vals[seriesIdx] / 4) * 110;
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(" ");
|
||||||
|
return (
|
||||||
|
<polyline key={seriesIdx}
|
||||||
|
points={pts}
|
||||||
|
fill="none"
|
||||||
|
stroke={stroke}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
opacity="0.85"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px 12px", marginTop: 6, fontSize: 11 }}>
|
||||||
|
{SITE_INVENTORY.slice(0, 5).map((it, i) => (
|
||||||
|
<span key={i} style={{ display: "inline-flex", alignItems: "center", gap: 5, color: "var(--muted)" }}>
|
||||||
|
<span style={{ width: 10, height: 2, background: ["var(--primary)", "oklch(55% 0.09 30)", "oklch(50% 0.06 150)", "oklch(50% 0.07 280)", "oklch(55% 0.08 60)"][i] }} />
|
||||||
|
{it.name.split("—")[0].trim().split(" ").slice(0,2).join(" ")}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Inventory</h2>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Product</th><th className="num">Qty on hand</th><th>Last updated</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{SITE_INVENTORY.map((it, i) => (
|
||||||
|
<tr key={i} className="clickable" onClick={() => go("item-detail", "p1")}>
|
||||||
|
<td>{it.name}</td>
|
||||||
|
<td className="num mono">{it.qty} ea</td>
|
||||||
|
<td className="muted">{it.updated}</td>
|
||||||
|
<td style={{ textAlign: "right" }}><Icon name="arrowRight" size={11} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<h2 className="section-title">Recent POs for this site</h2>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>PO Number</th><th>Status</th><th>Vendor</th><th>Created</th><th className="num">Amount</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ORDERS.slice(0, 5).map(o => (
|
||||||
|
<tr key={o.id} className="clickable" onClick={() => go("po-detail", o.id)}>
|
||||||
|
<td><span className="po-num">{o.id}</span></td>
|
||||||
|
<td><Badge status={o.status} /></td>
|
||||||
|
<td className="muted">{o.vendor || "—"}</td>
|
||||||
|
<td className="muted">{o.submitted || "—"}</td>
|
||||||
|
<td className="num">{inr(o.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Card title="Log consumption">
|
||||||
|
<div className="form-row" style={{ gap: 10 }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Product</label>
|
||||||
|
<select className="select">{SITE_INVENTORY.map((i, idx) => <option key={idx}>{i.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
<div className="form-row cols-2">
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Date</label>
|
||||||
|
<input className="input" type="date" defaultValue="2026-05-12" />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Quantity</label>
|
||||||
|
<input className="input mono" defaultValue="3" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Note (optional)</label>
|
||||||
|
<textarea className="textarea" style={{ minHeight: 56 }} placeholder="e.g. issued to MV Pelagia Voyager"></textarea>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime">Log consumption</button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ height: 14 }} />
|
||||||
|
|
||||||
|
<Card title="Assigned vessels">
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||||
|
<span className="tag-chip has-link">MV Pelagia Voyager</span>
|
||||||
|
<span className="tag-chip has-link">MV Coral Crescent</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ═══════════════════ VESSELS ═══════════════════ */
|
||||||
|
const VesselsPage = () => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Vessels"]} />
|
||||||
|
<h1 className="page-title">Vessels</h1>
|
||||||
|
<div className="page-sub">{VESSELS.length} vessels in service</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime"><Icon name="plus" /> Add vessel</button>
|
||||||
|
</div>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Name</th><th>IMO Number</th><th>Assigned site</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{VESSELS.map(v => (
|
||||||
|
<tr key={v.id}>
|
||||||
|
<td style={{ fontWeight: 500 }}>{v.name}</td>
|
||||||
|
<td className="mono">{v.imo}</td>
|
||||||
|
<td className="muted">{["Cochin Port Depot","Mumbai BPX Office","Visakhapatnam Yard","Chennai South Dock","Cochin Port Depot"][VESSELS.indexOf(v)]}</td>
|
||||||
|
<td><Badge className="closed" noDot>Active</Badge></td>
|
||||||
|
<td style={{ textAlign: "right" }}><button className="btn sm icon"><Icon name="edit" size={11} /></button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ ACCOUNTS ═══════════════════ */
|
||||||
|
const AccountsPage = () => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Administration", "Accounts"]} />
|
||||||
|
<h1 className="page-title">Accounts / Cost centres</h1>
|
||||||
|
<div className="page-sub">{ACCOUNTS.length} active cost centres</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime"><Icon name="plus" /> Add account</button>
|
||||||
|
</div>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Code</th><th>Name</th><th>Description</th><th>Status</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{ACCOUNTS.map(a => (
|
||||||
|
<tr key={a.id}>
|
||||||
|
<td className="mono" style={{ fontSize: 12 }}>{a.code}</td>
|
||||||
|
<td style={{ fontWeight: 500 }}>{a.name}</td>
|
||||||
|
<td className="muted">{a.desc}</td>
|
||||||
|
<td><Badge className="closed" noDot>Active</Badge></td>
|
||||||
|
<td style={{ textAlign: "right" }}><button className="btn sm icon"><Icon name="edit" size={11} /></button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ USERS ═══════════════════ */
|
||||||
|
const UsersPage = () => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Administration", "Users"]} />
|
||||||
|
<h1 className="page-title">Users</h1>
|
||||||
|
<div className="page-sub">{USERS.length} active users · 7 roles</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn maritime"><Icon name="plus" /> Add user</button>
|
||||||
|
</div>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead><tr><th>Employee ID</th><th>Name</th><th>Email</th><th>Role</th><th>Status</th><th>Created</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{USERS.map(u => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<td className="mono" style={{ fontSize: 12 }}>{u.emp}</td>
|
||||||
|
<td style={{ fontWeight: 500 }}>{u.name}</td>
|
||||||
|
<td className="muted">{u.email}</td>
|
||||||
|
<td><span className="role-badge">{u.role}</span></td>
|
||||||
|
<td><Badge className="closed" noDot>Active</Badge></td>
|
||||||
|
<td className="muted">{u.created}</td>
|
||||||
|
<td style={{ textAlign: "right" }}><button className="btn sm icon"><Icon name="edit" size={11} /></button></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ═══════════════════ CART ═══════════════════ */
|
||||||
|
const CartPage = ({ go }) => {
|
||||||
|
const [items, setItems] = useS([
|
||||||
|
{ id: 1, name: "Marine Bearing 6310-2RS", desc: "50×110×27mm sealed", vendor: "Mahalakshmi Marine Stores", price: 4250, qty: 24, gst: 18 },
|
||||||
|
{ id: 2, name: "Lube Oil Filter — Element", desc: "Cellulose, 10µ", vendor: "Coastline Engineering Co.", price: 1180, qty: 48, gst: 18 },
|
||||||
|
{ id: 3, name: "CO₂ Fire Extinguisher 9kg", desc: "Marine-grade, BIS certified", vendor: "Sealine Maritime Pvt Ltd", price: 8400, qty: 4, gst: 18 },
|
||||||
|
]);
|
||||||
|
const taxable = items.reduce((s, i) => s + i.price * i.qty, 0);
|
||||||
|
const gst = items.reduce((s, i) => s + i.price * i.qty * i.gst / 100, 0);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Inventory", "Cart"]} />
|
||||||
|
<h1 className="page-title">Cart</h1>
|
||||||
|
<div className="page-sub">{items.length} items · saved locally to this device</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: "flex", gap: 8 }}>
|
||||||
|
<button className="btn" onClick={() => setItems([])}>Clear cart</button>
|
||||||
|
<button className="btn maritime" onClick={() => go("po-new")}><Icon name="plus" /> Create PO from cart</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "2fr 1fr", gap: 16 }}>
|
||||||
|
<Card>
|
||||||
|
{items.length === 0 ? <div className="empty-state">Cart is empty. Add items from the Item catalogue.</div> : (
|
||||||
|
<>
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 80px 100px 80px 24px", gap: 14, padding: "0 0 8px", fontSize: 11, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.06em", borderBottom: "1px solid var(--line)" }}>
|
||||||
|
<div>Item · Vendor</div>
|
||||||
|
<div style={{ textAlign: "right" }}>Unit price</div>
|
||||||
|
<div style={{ textAlign: "center" }}>Quantity</div>
|
||||||
|
<div style={{ textAlign: "right" }}>Subtotal</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{items.map(it => (
|
||||||
|
<div className="cart-line" key={it.id}>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 500 }}>{it.name}</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11.5 }}>{it.desc} · <span style={{ color: "var(--primary-ink)" }}>{it.vendor}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="num mono">{inrFull(it.price)}</div>
|
||||||
|
<div style={{ display: "flex", justifyContent: "center" }}>
|
||||||
|
<input className="input mono" value={it.qty}
|
||||||
|
onChange={e => setItems(items.map(x => x.id === it.id ? { ...x, qty: Number(e.target.value) || 0 } : x))}
|
||||||
|
style={{ height: 28, width: 64, textAlign: "right", fontSize: 12 }} />
|
||||||
|
</div>
|
||||||
|
<div className="num mono">{inrFull(it.price * it.qty)}</div>
|
||||||
|
<button className="btn sm icon" onClick={() => setItems(items.filter(x => x.id !== it.id))}><Icon name="trash" size={11} /></button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Card title="Order summary">
|
||||||
|
<dl className="kv" style={{ gridTemplateColumns: "1fr auto", fontSize: 12.5 }}>
|
||||||
|
<dt>Taxable</dt> <dd className="mono">{inrFull(taxable)}</dd>
|
||||||
|
<dt>GST</dt> <dd className="mono">{inrFull(gst)}</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="divider" />
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline" }}>
|
||||||
|
<span style={{ fontSize: 12, color: "var(--muted)", textTransform: "uppercase", letterSpacing: "0.06em" }}>Grand total</span>
|
||||||
|
<span className="mono" style={{ fontSize: 20, fontWeight: 500 }}>{inrFull(taxable + gst)}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ height: 14 }} />
|
||||||
|
|
||||||
|
<Card title="Delivery site">
|
||||||
|
<div className="field">
|
||||||
|
<select className="select">
|
||||||
|
{SITES.map(s => <option key={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ fontSize: 11.5, marginTop: 6 }}>
|
||||||
|
The selected site pre-fills as place of delivery on the new PO.
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ═══════════════════ IMPORT PO ═══════════════════ */
|
||||||
|
const ImportPOPage = ({ go }) => (
|
||||||
|
<>
|
||||||
|
<div className="page-head">
|
||||||
|
<div>
|
||||||
|
<Crumbs items={["Purchase Orders", "Import PO"]} />
|
||||||
|
<h1 className="page-title">Import PO from Excel</h1>
|
||||||
|
<div className="page-sub">Upload a file in Pelagia's standard PO template format</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: "grid", gridTemplateColumns: "1.6fr 1fr", gap: 16, marginBottom: 16 }}>
|
||||||
|
<Card title="1 · Upload file">
|
||||||
|
<div style={{ padding: 28, border: "1.5px dashed var(--line-2)", borderRadius: 8, textAlign: "center", color: "var(--muted)" }}>
|
||||||
|
<Icon name="upload" /> Drop .xlsx file here or <span style={{ color: "var(--primary-ink)" }}>browse</span>
|
||||||
|
<div className="faint" style={{ fontSize: 11.5, marginTop: 4 }}>Template downloadable from /docs/po-template.xlsx</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 12, padding: "8px 10px", background: "var(--paper-2)", borderRadius: 6, fontSize: 12.5, display: "flex", alignItems: "center", gap: 10 }}>
|
||||||
|
<Icon name="check" />
|
||||||
|
<span><strong>Q-Mahalakshmi-2841.xlsx</strong> · 84 KB · parsed</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<span className="muted" style={{ fontSize: 11.5 }}>5 line items detected</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="2 · Pelagia metadata">
|
||||||
|
<div className="form-row" style={{ gap: 12 }}>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Vessel <span className="req">*</span></label>
|
||||||
|
<select className="select"><option>MV Pelagia Voyager</option>{VESSELS.slice(1).map(v => <option key={v.id}>{v.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="field-label">Account <span className="req">*</span></label>
|
||||||
|
<select className="select"><option>ENGINE — Engine Department</option>{ACCOUNTS.slice(0, 4).map(a => <option key={a.id}>{a.code} — {a.name}</option>)}</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="section-title">3 · Extracted line items — review before saving</h2>
|
||||||
|
<Card flush>
|
||||||
|
<table className="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Item</th><th>Description</th><th className="num">Qty</th><th>Unit</th>
|
||||||
|
<th className="num">Unit price</th><th className="num">GST</th><th className="num">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ORDERS[0].items.concat([
|
||||||
|
{ name: "Marine Grease — EP2", desc: "Lithium complex, 18kg pail", qty: 6, unit: "ea", price: 4800, gst: 18 },
|
||||||
|
{ name: "O-Ring Set — FKM", desc: "Assorted sizes 40-pc kit", qty: 2, unit: "set", price: 2400, gst: 18 },
|
||||||
|
]).map((i, idx) => (
|
||||||
|
<tr key={idx}>
|
||||||
|
<td>{i.name}</td>
|
||||||
|
<td className="muted">{i.desc}</td>
|
||||||
|
<td className="num">{i.qty}</td>
|
||||||
|
<td className="muted">{i.unit}</td>
|
||||||
|
<td className="num mono">{inrFull(i.price)}</td>
|
||||||
|
<td className="num muted">{i.gst}%</td>
|
||||||
|
<td className="num mono">{inrFull(i.qty * i.price * (1 + i.gst / 100))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div style={{ marginTop: 18, display: "flex", justifyContent: "flex-end", gap: 8 }}>
|
||||||
|
<button className="btn" onClick={() => go("my-orders")}>Cancel</button>
|
||||||
|
<button className="btn maritime">Save as Draft</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.assign(window, { SitesPage, SiteDetailPage, VesselsPage, AccountsPage, UsersPage, CartPage, ImportPOPage });
|
||||||
604
design_handoff_pelagia_portal/styles.css
Normal file
604
design_handoff_pelagia_portal/styles.css
Normal file
|
|
@ -0,0 +1,604 @@
|
||||||
|
/* Pelagia Portal — design tokens & base styles */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Palette — warm-cool paper, ink navy, single maritime primary */
|
||||||
|
--paper: oklch(98% 0.005 240);
|
||||||
|
--paper-2: oklch(96.5% 0.006 240);
|
||||||
|
--surface: #ffffff;
|
||||||
|
--ink: oklch(22% 0.02 245);
|
||||||
|
--ink-2: oklch(35% 0.02 245);
|
||||||
|
--muted: oklch(55% 0.015 245);
|
||||||
|
--faint: oklch(72% 0.012 245);
|
||||||
|
--line: oklch(91% 0.006 245);
|
||||||
|
--line-2: oklch(87% 0.008 245);
|
||||||
|
|
||||||
|
--primary: oklch(38% 0.06 230);
|
||||||
|
--primary-ink: oklch(28% 0.06 230);
|
||||||
|
--primary-soft: oklch(95% 0.02 230);
|
||||||
|
|
||||||
|
/* Status badges — distinct hue, similar chroma/lightness */
|
||||||
|
--st-draft-bg: oklch(94% 0.005 245); --st-draft-fg: oklch(40% 0.015 245);
|
||||||
|
--st-review-bg: oklch(94% 0.03 245); --st-review-fg: oklch(38% 0.09 245);
|
||||||
|
--st-vendor-bg: oklch(94% 0.04 60); --st-vendor-fg: oklch(42% 0.12 60);
|
||||||
|
--st-edits-bg: oklch(95% 0.05 95); --st-edits-fg: oklch(42% 0.1 80);
|
||||||
|
--st-approved-bg: oklch(94% 0.04 190); --st-approved-fg: oklch(38% 0.08 195);
|
||||||
|
--st-sent-bg: oklch(94% 0.04 300); --st-sent-fg: oklch(40% 0.1 300);
|
||||||
|
--st-paid-bg: oklch(94% 0.04 215); --st-paid-fg: oklch(38% 0.08 220);
|
||||||
|
--st-closed-bg: oklch(94% 0.04 150); --st-closed-fg: oklch(38% 0.08 150);
|
||||||
|
--st-rejected-bg: oklch(94% 0.04 25); --st-rejected-fg: oklch(45% 0.13 28);
|
||||||
|
|
||||||
|
/* Type */
|
||||||
|
--font-sans: "Geist", "Inter", system-ui, -apple-system, sans-serif;
|
||||||
|
--font-mono: "Geist Mono", "JetBrains Mono", ui-monospace, monospace;
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--r-sm: 4px;
|
||||||
|
--r-md: 6px;
|
||||||
|
--r-lg: 10px;
|
||||||
|
|
||||||
|
--sidebar-w: 232px;
|
||||||
|
--header-h: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-feature-settings: "cv11", "ss01";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
font: inherit; color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
|
||||||
|
.mono { font-family: var(--font-mono); font-feature-settings: "tnum" 1; letter-spacing: -0.01em; }
|
||||||
|
.tnum { font-variant-numeric: tabular-nums; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.faint { color: var(--faint); }
|
||||||
|
|
||||||
|
/* ─────────── App shell ─────────── */
|
||||||
|
.app {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: var(--sidebar-w) 1fr;
|
||||||
|
grid-template-rows: var(--header-h) 1fr;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px 0 18px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
gap: 14px;
|
||||||
|
height: var(--header-h);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
font-weight: 600; letter-spacing: -0.01em;
|
||||||
|
width: calc(var(--sidebar-w) - 18px);
|
||||||
|
}
|
||||||
|
.brand-mark {
|
||||||
|
width: 22px; height: 22px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--primary);
|
||||||
|
position: relative; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.brand-mark::before {
|
||||||
|
content: ""; position: absolute; inset: 4px;
|
||||||
|
border: 1.5px solid var(--paper); border-top-color: transparent; border-right-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
}
|
||||||
|
.brand-name { font-size: 14px; }
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
flex: 0 0 320px;
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
height: 30px; padding: 0 10px;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.header-search input {
|
||||||
|
flex: 1; background: transparent; border: 0; outline: 0;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.header-search .kbd {
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
padding: 1px 5px; border: 1px solid var(--line-2);
|
||||||
|
border-radius: 3px; color: var(--muted);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-spacer { flex: 1; }
|
||||||
|
|
||||||
|
.role-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 8px;
|
||||||
|
height: 30px; padding: 0 4px 0 10px;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.role-pill select {
|
||||||
|
background: transparent; border: 0; outline: 0;
|
||||||
|
padding: 0 18px 0 4px;
|
||||||
|
font-family: var(--font-mono); font-size: 11.5px;
|
||||||
|
-webkit-appearance: none; appearance: none;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%), linear-gradient(135deg, var(--muted) 50%, transparent 50%);
|
||||||
|
background-position: calc(100% - 9px) 13px, calc(100% - 5px) 13px;
|
||||||
|
background-size: 4px 4px; background-repeat: no-repeat;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-chip {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.user-avatar {
|
||||||
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
|
background: var(--primary); color: var(--paper);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
font-size: 11px; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────── Sidebar ─────────── */
|
||||||
|
.sidebar {
|
||||||
|
border-right: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
padding: 14px 10px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.nav-group { margin-bottom: 14px; }
|
||||||
|
.nav-group-title {
|
||||||
|
font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--faint); padding: 6px 10px 4px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--ink-2);
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 1px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: var(--paper-2); color: var(--ink); }
|
||||||
|
.nav-item.active {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
color: var(--primary-ink);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.nav-item .nav-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--paper-2);
|
||||||
|
border-radius: 8px; padding: 1px 6px;
|
||||||
|
}
|
||||||
|
.nav-item.active .nav-count { background: var(--surface); color: var(--primary-ink); }
|
||||||
|
.nav-icon {
|
||||||
|
width: 14px; height: 14px; flex-shrink: 0; color: var(--muted);
|
||||||
|
}
|
||||||
|
.nav-item.active .nav-icon { color: var(--primary-ink); }
|
||||||
|
|
||||||
|
/* ─────────── Main ─────────── */
|
||||||
|
.main {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 22px 32px 60px;
|
||||||
|
max-width: 1280px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
display: flex; align-items: flex-end; justify-content: space-between;
|
||||||
|
margin-bottom: 22px; gap: 16px;
|
||||||
|
}
|
||||||
|
.crumbs {
|
||||||
|
font-size: 11.5px; color: var(--muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex; gap: 6px;
|
||||||
|
}
|
||||||
|
.crumbs .sep { color: var(--faint); }
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px; font-weight: 600; letter-spacing: -0.01em;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.page-sub { color: var(--muted); font-size: 12.5px; margin-top: 4px; }
|
||||||
|
|
||||||
|
/* ─────────── Buttons ─────────── */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
|
height: 30px; padding: 0 12px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
.btn:hover { background: var(--paper-2); border-color: var(--line-2); }
|
||||||
|
.btn.primary {
|
||||||
|
background: var(--ink); color: var(--paper);
|
||||||
|
border-color: var(--ink);
|
||||||
|
}
|
||||||
|
.btn.primary:hover { background: oklch(15% 0.02 245); }
|
||||||
|
.btn.maritime {
|
||||||
|
background: var(--primary); color: var(--paper);
|
||||||
|
border-color: var(--primary);
|
||||||
|
}
|
||||||
|
.btn.maritime:hover { background: var(--primary-ink); }
|
||||||
|
.btn.danger {
|
||||||
|
background: var(--surface); color: var(--st-rejected-fg);
|
||||||
|
border-color: var(--st-rejected-bg);
|
||||||
|
}
|
||||||
|
.btn.danger:hover { background: var(--st-rejected-bg); }
|
||||||
|
.btn.sm { height: 26px; padding: 0 9px; font-size: 11.5px; }
|
||||||
|
.btn.icon { padding: 0; width: 30px; justify-content: center; }
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ─────────── Cards & sections ─────────── */
|
||||||
|
.card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
padding: 13px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.card-title { font-size: 13px; font-weight: 600; margin: 0; }
|
||||||
|
.card-body { padding: 16px; }
|
||||||
|
.card-body.flush { padding: 0; }
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
|
color: var(--muted); font-weight: 500;
|
||||||
|
margin: 26px 0 10px;
|
||||||
|
}
|
||||||
|
.section-title:first-child { margin-top: 0; }
|
||||||
|
|
||||||
|
/* ─────────── Tables ─────────── */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--paper-2);
|
||||||
|
}
|
||||||
|
.table td {
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.table tr:last-child td { border-bottom: 0; }
|
||||||
|
.table tr.clickable:hover td { background: var(--paper-2); cursor: pointer; }
|
||||||
|
.table .num { font-family: var(--font-mono); font-variant-numeric: tabular-nums; text-align: right; }
|
||||||
|
.table .po-num { font-family: var(--font-mono); font-size: 12px; color: var(--primary-ink); }
|
||||||
|
|
||||||
|
/* ─────────── Status badges ─────────── */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 100px;
|
||||||
|
font-size: 10.5px; font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge::before {
|
||||||
|
content: ""; width: 5px; height: 5px; border-radius: 50%;
|
||||||
|
background: currentColor;
|
||||||
|
}
|
||||||
|
.badge.no-dot::before { display: none; }
|
||||||
|
|
||||||
|
.badge.draft { background: var(--st-draft-bg); color: var(--st-draft-fg); }
|
||||||
|
.badge.submitted { background: var(--st-review-bg); color: var(--st-review-fg); }
|
||||||
|
.badge.review { background: var(--st-review-bg); color: var(--st-review-fg); }
|
||||||
|
.badge.vendor { background: var(--st-vendor-bg); color: var(--st-vendor-fg); }
|
||||||
|
.badge.edits { background: var(--st-edits-bg); color: var(--st-edits-fg); }
|
||||||
|
.badge.approved { background: var(--st-approved-bg); color: var(--st-approved-fg); }
|
||||||
|
.badge.sent { background: var(--st-sent-bg); color: var(--st-sent-fg); }
|
||||||
|
.badge.paid { background: var(--st-paid-bg); color: var(--st-paid-fg); }
|
||||||
|
.badge.closed { background: var(--st-closed-bg); color: var(--st-closed-fg); }
|
||||||
|
.badge.rejected { background: var(--st-rejected-bg); color: var(--st-rejected-fg); }
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
display: inline-flex; padding: 2px 7px;
|
||||||
|
background: var(--paper-2); border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: var(--font-mono); font-size: 10.5px;
|
||||||
|
color: var(--ink-2); letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verified-mark {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 11px; color: var(--st-closed-fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────── Stat cards ─────────── */
|
||||||
|
.stat-grid {
|
||||||
|
display: grid; gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
|
||||||
|
color: var(--muted); font-weight: 500;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 26px; font-weight: 500;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.stat-sub {
|
||||||
|
font-size: 11.5px; color: var(--muted);
|
||||||
|
margin-top: 3px;
|
||||||
|
display: flex; gap: 6px; align-items: center;
|
||||||
|
}
|
||||||
|
.stat .trend-up { color: var(--st-closed-fg); }
|
||||||
|
.stat .trend-dn { color: var(--st-rejected-fg); }
|
||||||
|
.stat.clickable { cursor: pointer; transition: border-color 0.12s; }
|
||||||
|
.stat.clickable:hover { border-color: var(--ink-2); }
|
||||||
|
|
||||||
|
/* ─────────── Forms ─────────── */
|
||||||
|
.field { display: flex; flex-direction: column; gap: 5px; }
|
||||||
|
.field-label {
|
||||||
|
font-size: 11px; color: var(--muted); font-weight: 500;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.field-label .req { color: var(--st-rejected-fg); margin-left: 2px; }
|
||||||
|
.input, .select, .textarea {
|
||||||
|
height: 32px; padding: 0 11px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 13px;
|
||||||
|
outline: 0;
|
||||||
|
transition: border-color 0.12s, box-shadow 0.12s;
|
||||||
|
}
|
||||||
|
.input:focus, .select:focus, .textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--primary-soft);
|
||||||
|
}
|
||||||
|
.textarea { height: auto; padding: 9px 11px; resize: vertical; min-height: 72px; }
|
||||||
|
.input.mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid; gap: 14px;
|
||||||
|
}
|
||||||
|
.form-row.cols-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.form-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
|
||||||
|
.form-row.cols-4 { grid-template-columns: 1fr 1fr 1fr 1fr; }
|
||||||
|
|
||||||
|
/* ─────────── Filter bar ─────────── */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex; flex-wrap: wrap; gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--paper-2);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
}
|
||||||
|
.filter-bar .input,
|
||||||
|
.filter-bar .select { height: 28px; font-size: 12px; background: var(--surface); }
|
||||||
|
|
||||||
|
/* ─────────── Charts (CSS-only bars) ─────────── */
|
||||||
|
.bar-chart { display: flex; align-items: flex-end; gap: 6px; height: 140px; }
|
||||||
|
.bar {
|
||||||
|
flex: 1; background: var(--primary-soft);
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
position: relative;
|
||||||
|
min-height: 4px;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
.bar:hover { background: var(--primary); }
|
||||||
|
.bar::after {
|
||||||
|
content: attr(data-label);
|
||||||
|
position: absolute; bottom: -18px; left: 0; right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10.5px; color: var(--muted);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
.bar .bar-val {
|
||||||
|
position: absolute; top: -16px; left: 0; right: 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10px; color: var(--ink-2);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
opacity: 0; transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.bar:hover .bar-val { opacity: 1; }
|
||||||
|
|
||||||
|
.hbar-row {
|
||||||
|
display: grid; grid-template-columns: 110px 1fr 80px;
|
||||||
|
gap: 10px; align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 7px;
|
||||||
|
}
|
||||||
|
.hbar-row .name { color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.hbar-row .track {
|
||||||
|
background: var(--paper-2);
|
||||||
|
height: 12px; border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.hbar-row .fill { background: var(--primary); height: 100%; border-radius: 3px; }
|
||||||
|
.hbar-row .v { font-family: var(--font-mono); text-align: right; color: var(--ink); font-size: 11.5px; }
|
||||||
|
|
||||||
|
/* ─────────── PO Detail layout ─────────── */
|
||||||
|
.detail-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 280px;
|
||||||
|
gap: 22px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.detail-band {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.detail-band-left { display: flex; align-items: center; gap: 14px; }
|
||||||
|
.detail-band .po-id {
|
||||||
|
font-family: var(--font-mono); font-size: 18px;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.detail-band .po-title { color: var(--muted); font-size: 13.5px; }
|
||||||
|
|
||||||
|
.kv {
|
||||||
|
display: grid; grid-template-columns: 130px 1fr;
|
||||||
|
gap: 10px 14px; font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.kv dt { color: var(--muted); }
|
||||||
|
.kv dd { margin: 0; color: var(--ink); }
|
||||||
|
|
||||||
|
.timeline-stop {
|
||||||
|
display: grid; grid-template-columns: 14px 1fr auto;
|
||||||
|
gap: 10px; align-items: flex-start;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px dashed var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.timeline-stop:last-child { border-bottom: 0; }
|
||||||
|
.timeline-stop .dot {
|
||||||
|
width: 8px; height: 8px; border-radius: 50%;
|
||||||
|
background: var(--line-2);
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.timeline-stop.done .dot { background: var(--st-closed-fg); }
|
||||||
|
.timeline-stop .actor { color: var(--ink); font-weight: 500; }
|
||||||
|
.timeline-stop .action { color: var(--muted); }
|
||||||
|
.timeline-stop .when { color: var(--faint); font-family: var(--font-mono); font-size: 11px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ─────────── Inline action panel ─────────── */
|
||||||
|
.action-panel {
|
||||||
|
background: oklch(99% 0.005 245);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
.action-row { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* ─────────── Payment cards ─────────── */
|
||||||
|
.pay-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.pay-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 16px;
|
||||||
|
display: flex; flex-direction: column; gap: 10px;
|
||||||
|
}
|
||||||
|
.pay-card .amount {
|
||||||
|
font-family: var(--font-mono); font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 22px; letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────── Cart ─────────── */
|
||||||
|
.cart-line {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 80px 100px 80px 24px;
|
||||||
|
gap: 14px; align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.cart-line:last-of-type { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* ─────────── Login ─────────── */
|
||||||
|
.login-shell {
|
||||||
|
display: grid; place-items: center;
|
||||||
|
min-height: 100vh; background: var(--paper);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 360px;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 28px;
|
||||||
|
}
|
||||||
|
.login-brand {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
}
|
||||||
|
.login-brand .brand-mark { width: 28px; height: 28px; }
|
||||||
|
.login-brand .brand-name { font-size: 18px; }
|
||||||
|
|
||||||
|
/* ─────────── Misc helpers ─────────── */
|
||||||
|
.divider { height: 1px; background: var(--line); margin: 16px 0; }
|
||||||
|
.tag-chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 5px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: var(--paper-2); border: 1px solid var(--line);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11.5px;
|
||||||
|
margin: 2px 4px 2px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.tag-chip:hover { border-color: var(--ink-2); }
|
||||||
|
.tag-chip.has-link { color: var(--primary-ink); }
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
display: flex; gap: 10px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 12.5px;
|
||||||
|
border: 1px solid var(--st-edits-bg);
|
||||||
|
background: oklch(97% 0.02 95);
|
||||||
|
color: var(--st-edits-fg);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.alert.info { border-color: var(--st-review-bg); background: oklch(97% 0.012 245); color: var(--st-review-fg); }
|
||||||
|
.alert strong { color: inherit; }
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 38px 16px; text-align: center;
|
||||||
|
color: var(--muted); font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot-sep { color: var(--faint); padding: 0 6px; }
|
||||||
|
|
||||||
|
.scrollable-card { max-height: 340px; overflow-y: auto; }
|
||||||
|
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: var(--line-2); border-radius: 10px; border: 2px solid var(--surface); }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: var(--faint); }
|
||||||
349
generate_po.py
Normal file
349
generate_po.py
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
"""
|
||||||
|
Generate PO output: XLSX (copy with computed values) + PDF (formatted).
|
||||||
|
Source: Prototype/Sample_PO.xlsx
|
||||||
|
Output: Progress/PMS_HNR3_056_2026-27.xlsx + Progress/PMS_HNR3_056_2026-27.pdf
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil, os, datetime
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
# ── paths ────────────────────────────────────────────────────────────────────
|
||||||
|
BASE = r'C:\Users\shad0w\Documents\src\Peliagia_Portal'
|
||||||
|
SRC_XLSX = os.path.join(BASE, r'Prototype\Sample_PO.xlsx')
|
||||||
|
OUT_DIR = os.path.join(BASE, 'Progress')
|
||||||
|
OUT_XLSX = os.path.join(OUT_DIR, 'PMS_HNR3_056_2026-27.xlsx')
|
||||||
|
OUT_PDF = os.path.join(OUT_DIR, 'PMS_HNR3_056_2026-27.pdf')
|
||||||
|
|
||||||
|
os.makedirs(OUT_DIR, exist_ok=True)
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Computed values (resolved from the string-formula cells)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
QTY = 1050
|
||||||
|
UNIT_PRICE = 182
|
||||||
|
TAXABLE = QTY * UNIT_PRICE # 191,100
|
||||||
|
GST_RATE = 0.18
|
||||||
|
GST_AMT = round(TAXABLE * GST_RATE) # 34,398
|
||||||
|
GRAND_TOTAL= TAXABLE + GST_AMT # 225,498
|
||||||
|
PO_NO = 'PMS/HNR3/056/2026-27'
|
||||||
|
PO_DATE = datetime.date(2026, 4, 29)
|
||||||
|
VENDOR = 'Apar Industries Ltd'
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 1. XLSX — copy the template, replace text-formula cells with real formulas
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
shutil.copy2(SRC_XLSX, OUT_XLSX)
|
||||||
|
wb = load_workbook(OUT_XLSX)
|
||||||
|
ws = wb.active
|
||||||
|
|
||||||
|
# The source stores these as Excel formula strings — they're already valid
|
||||||
|
# formulas in the file; openpyxl reads them back as strings starting with '='.
|
||||||
|
# We re-write them so openpyxl treats them as formulas (no data_only quirk).
|
||||||
|
ws['G16'] = '=F16*E16'
|
||||||
|
ws['I16'] = '=G16+H16*G16'
|
||||||
|
ws['H24'] = '=SUM(G16:G22)'
|
||||||
|
ws['H25'] = '=H24*18%'
|
||||||
|
ws['H26'] = '=H24+H25'
|
||||||
|
ws['D26'] = '=SUM(D16:D20)'
|
||||||
|
ws['G39'] = '=C13'
|
||||||
|
|
||||||
|
# Format date cell properly
|
||||||
|
ws['I5'] = PO_DATE
|
||||||
|
ws['I5'].number_format = 'DD-MMM-YYYY'
|
||||||
|
|
||||||
|
wb.save(OUT_XLSX)
|
||||||
|
print(f'[XLSX] Saved: {OUT_XLSX}')
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
# 2. PDF — render with reportlab
|
||||||
|
# ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
from reportlab.lib.pagesizes import A4
|
||||||
|
from reportlab.lib.units import mm
|
||||||
|
from reportlab.lib import colors
|
||||||
|
from reportlab.lib.styles import ParagraphStyle
|
||||||
|
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
|
||||||
|
from reportlab.platypus import (
|
||||||
|
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, HRFlowable
|
||||||
|
)
|
||||||
|
from reportlab.pdfbase import pdfmetrics
|
||||||
|
from reportlab.pdfbase.ttfonts import TTFont
|
||||||
|
|
||||||
|
W, H = A4
|
||||||
|
MARGIN = 12 * mm
|
||||||
|
|
||||||
|
def mk_style(name, **kw):
|
||||||
|
return ParagraphStyle(name, **kw)
|
||||||
|
|
||||||
|
HEAD = mk_style('Head', fontName='Helvetica-Bold', fontSize=14, alignment=TA_CENTER, spaceAfter=1)
|
||||||
|
SUB = mk_style('Sub', fontName='Helvetica', fontSize=8, alignment=TA_CENTER, spaceAfter=1)
|
||||||
|
TITLE = mk_style('Title', fontName='Helvetica-Bold', fontSize=12, alignment=TA_CENTER, spaceAfter=4)
|
||||||
|
LABEL = mk_style('Label', fontName='Helvetica-Bold', fontSize=7.5, alignment=TA_LEFT)
|
||||||
|
VALUE = mk_style('Value', fontName='Helvetica', fontSize=7.5, alignment=TA_LEFT)
|
||||||
|
VALUEC = mk_style('ValueC', fontName='Helvetica', fontSize=7.5, alignment=TA_CENTER)
|
||||||
|
VALUER = mk_style('ValueR', fontName='Helvetica', fontSize=7.5, alignment=TA_RIGHT)
|
||||||
|
BOLDVAL = mk_style('BoldV', fontName='Helvetica-Bold', fontSize=7.5, alignment=TA_CENTER)
|
||||||
|
INSTRH = mk_style('InstrH', fontName='Helvetica-Bold', fontSize=7.5, alignment=TA_CENTER)
|
||||||
|
|
||||||
|
BLK = colors.black
|
||||||
|
GRAY = colors.HexColor('#D0D0D0')
|
||||||
|
LGRY = colors.HexColor('#F0F0F0')
|
||||||
|
|
||||||
|
def thin_box():
|
||||||
|
return [
|
||||||
|
('BOX', (0, 0), (-1, -1), 0.5, BLK),
|
||||||
|
('INNERGRID', (0, 0), (-1, -1), 0.3, BLK),
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 7.5),
|
||||||
|
('FONTNAME', (0, 0), (-1, -1), 'Helvetica'),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
||||||
|
('RIGHTPADDING',(0, 0), (-1, -1), 3),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
||||||
|
('BOTTOMPADDING',(0, 0), (-1, -1), 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
story = []
|
||||||
|
TW = W - 2 * MARGIN # total usable width
|
||||||
|
|
||||||
|
# ── Header ───────────────────────────────────────────────────────────────────
|
||||||
|
story.append(Paragraph('PELAGIA MARINE SERVICES PVT. LTD', HEAD))
|
||||||
|
story.append(Paragraph(
|
||||||
|
'Office address: 409-410, ZION, Plot 273, Sector-10, Kharghar, Navi Mumbai- 410210', SUB))
|
||||||
|
story.append(Paragraph(
|
||||||
|
'Tel: +91-22-6909 9028 / Email: technical@pelagiamarine.com / Mob: +91 74000 60772', SUB))
|
||||||
|
story.append(HRFlowable(width=TW, thickness=1, color=BLK, spaceAfter=3))
|
||||||
|
story.append(Paragraph('PURCHASE ORDER', TITLE))
|
||||||
|
story.append(HRFlowable(width=TW, thickness=1, color=BLK, spaceAfter=3))
|
||||||
|
|
||||||
|
# ── PO meta table ────────────────────────────────────────────────────────────
|
||||||
|
po_date_str = PO_DATE.strftime('%d-%b-%Y')
|
||||||
|
|
||||||
|
meta_data = [
|
||||||
|
[
|
||||||
|
Paragraph('<b>Purchase Order No:</b>', LABEL),
|
||||||
|
Paragraph(PO_NO, BOLDVAL),
|
||||||
|
Paragraph('<b>Date:</b>', LABEL),
|
||||||
|
Paragraph(po_date_str, VALUEC),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph('<b>Performa Invoice / Quotation No:</b>', LABEL),
|
||||||
|
Paragraph('Verbal', VALUEC),
|
||||||
|
Paragraph('<b>P I / Quotation Date:</b>', LABEL),
|
||||||
|
Paragraph('', VALUEC),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
col_w = [TW * 0.32, TW * 0.24, TW * 0.22, TW * 0.22]
|
||||||
|
meta_tbl = Table(meta_data, colWidths=col_w, repeatRows=0)
|
||||||
|
meta_tbl.setStyle(TableStyle(thin_box() + [
|
||||||
|
('FONTNAME', (1, 0), (1, 0), 'Helvetica-Bold'),
|
||||||
|
]))
|
||||||
|
story.append(meta_tbl)
|
||||||
|
|
||||||
|
# ── Vessel / Requisition / Approved ──────────────────────────────────────────
|
||||||
|
vessel_data = [
|
||||||
|
[
|
||||||
|
Paragraph('<b>Vessel Owner Name</b>', LABEL),
|
||||||
|
Paragraph('Pelagia Marine Services Pvt. Ltd.', VALUE),
|
||||||
|
Paragraph('<b>Budget head</b>', LABEL),
|
||||||
|
Paragraph('700203', VALUEC),
|
||||||
|
Paragraph('<b>Requested By</b>', LABEL),
|
||||||
|
Paragraph('Kaushal Pal Singh', VALUE),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph('<b>Vessel/Office Requisition No.</b>', LABEL),
|
||||||
|
Paragraph('', VALUE),
|
||||||
|
Paragraph('<b>Reqn. Date</b>', LABEL),
|
||||||
|
Paragraph('', VALUEC),
|
||||||
|
Paragraph('<b>Approved By</b>', LABEL),
|
||||||
|
Paragraph('Kaushal Pal Singh', VALUE),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
v_w = [TW*0.20, TW*0.17, TW*0.12, TW*0.10, TW*0.14, TW*0.27]
|
||||||
|
vessel_tbl = Table(vessel_data, colWidths=v_w)
|
||||||
|
vessel_tbl.setStyle(TableStyle(thin_box()))
|
||||||
|
story.append(vessel_tbl)
|
||||||
|
|
||||||
|
# ── Place of Delivery + Invoice Details ──────────────────────────────────────
|
||||||
|
delivery_data = [
|
||||||
|
[
|
||||||
|
Paragraph('<b>Place of Delivery</b>', LABEL),
|
||||||
|
Paragraph(
|
||||||
|
'Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, '
|
||||||
|
'CBD Belapur, Navi Mumbai - 400614', VALUE),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph('<b>Invoice Details</b>', LABEL),
|
||||||
|
Paragraph(
|
||||||
|
'Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, '
|
||||||
|
'Kharghar, Navi Mumbai- 410210 (MH)<br/>'
|
||||||
|
'Email: accounts@pelagiamarine.com GST NO: 27AAHCP5787B1Z6', VALUE),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
d_w = [TW*0.22, TW*0.78]
|
||||||
|
delivery_tbl = Table(delivery_data, colWidths=d_w)
|
||||||
|
delivery_tbl.setStyle(TableStyle(thin_box()))
|
||||||
|
story.append(delivery_tbl)
|
||||||
|
|
||||||
|
# ── Vendor ────────────────────────────────────────────────────────────────────
|
||||||
|
vendor_data = [
|
||||||
|
[
|
||||||
|
Paragraph('<b>Vendor Name & Address</b>', LABEL),
|
||||||
|
Paragraph(VENDOR, VALUE),
|
||||||
|
Paragraph(
|
||||||
|
'18, TTC MIDC Industrial Area Thane Belapur Road, Opp Rabale Railway Stn '
|
||||||
|
'Rabale, Navi Mumbai 400701 GSTIN: 27AAACG1840M1ZL', VALUE),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph('<b>Contact Person / Mobile</b>', LABEL),
|
||||||
|
Paragraph(
|
||||||
|
'Mr. Nikhil Mumbaikar Ph. 7208055636 '
|
||||||
|
'Email: nikhil.mumbaikar@apar.com', VALUE),
|
||||||
|
Paragraph('', VALUE),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
vd_w = [TW*0.22, TW*0.22, TW*0.56]
|
||||||
|
vendor_tbl = Table(vendor_data, colWidths=vd_w)
|
||||||
|
vendor_tbl.setStyle(TableStyle(thin_box()))
|
||||||
|
story.append(vendor_tbl)
|
||||||
|
|
||||||
|
# ── Line items ────────────────────────────────────────────────────────────────
|
||||||
|
items_header = [
|
||||||
|
Paragraph('<b>S.N.</b>', BOLDVAL),
|
||||||
|
Paragraph('<b>Description</b>', BOLDVAL),
|
||||||
|
Paragraph('<b>Unit</b>', BOLDVAL),
|
||||||
|
Paragraph('<b>Qty</b>', BOLDVAL),
|
||||||
|
Paragraph('<b>Unit Price</b>', BOLDVAL),
|
||||||
|
Paragraph('<b>Taxable Cost</b>', BOLDVAL),
|
||||||
|
Paragraph('<b>GST %</b>', BOLDVAL),
|
||||||
|
Paragraph('<b>Total Cost</b>', BOLDVAL),
|
||||||
|
]
|
||||||
|
items_row = [
|
||||||
|
Paragraph('1', VALUEC),
|
||||||
|
Paragraph('Eni EP 80W90 GEAR OIL', VALUE),
|
||||||
|
Paragraph('Ltr', VALUEC),
|
||||||
|
Paragraph(f'{QTY:,}', VALUEC),
|
||||||
|
Paragraph(f'{UNIT_PRICE:,.2f}', VALUER),
|
||||||
|
Paragraph(f'{TAXABLE:,.2f}', VALUER),
|
||||||
|
Paragraph('18%', VALUEC),
|
||||||
|
Paragraph(f'{GRAND_TOTAL:,.2f}', VALUER),
|
||||||
|
]
|
||||||
|
# blank filler rows
|
||||||
|
blank = [''] * 8
|
||||||
|
items_data = [items_header, items_row] + [blank] * 6
|
||||||
|
|
||||||
|
i_w = [TW*0.05, TW*0.29, TW*0.07, TW*0.07, TW*0.10, TW*0.14, TW*0.08, TW*0.20]
|
||||||
|
items_tbl = Table(items_data, colWidths=i_w, rowHeights=[None] + [10*mm]*7)
|
||||||
|
items_tbl.setStyle(TableStyle(thin_box() + [
|
||||||
|
('BACKGROUND', (0, 0), (-1, 0), LGRY),
|
||||||
|
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||||
|
]))
|
||||||
|
story.append(items_tbl)
|
||||||
|
|
||||||
|
# ── Totals ────────────────────────────────────────────────────────────────────
|
||||||
|
totals_data = [
|
||||||
|
['', '', '', '', '', Paragraph('<b>Total Taxable Value</b>', LABEL), '', Paragraph(f'{TAXABLE:,.2f}', VALUER)],
|
||||||
|
['', '', '', '', '', Paragraph('<b>GST (18%)</b>', LABEL), '', Paragraph(f'{GST_AMT:,.2f}', VALUER)],
|
||||||
|
[
|
||||||
|
Paragraph(f'Total Qty: {QTY:,} Ltr', VALUE),
|
||||||
|
'', '', '',
|
||||||
|
'',
|
||||||
|
Paragraph('<b>GRAND TOTAL</b>', LABEL),
|
||||||
|
'',
|
||||||
|
Paragraph(f'<b>{GRAND_TOTAL:,.2f}</b>', mk_style('GT', fontName='Helvetica-Bold', fontSize=7.5, alignment=TA_RIGHT)),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
totals_tbl = Table(totals_data, colWidths=i_w)
|
||||||
|
totals_tbl.setStyle(TableStyle([
|
||||||
|
('BOX', (0, 0), (-1, -1), 0.5, BLK),
|
||||||
|
('INNERGRID', (0, 0), (-1, -1), 0.3, BLK),
|
||||||
|
('SPAN', (0, 2), (3, 2)), # total qty spans cols 0-3
|
||||||
|
('SPAN', (5, 0), (6, 0)), # label spans 5-6 row 0
|
||||||
|
('SPAN', (5, 1), (6, 1)),
|
||||||
|
('SPAN', (5, 2), (6, 2)),
|
||||||
|
('ALIGN', (7, 0), (7, 2), 'RIGHT'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 7.5),
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
||||||
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
||||||
|
('BOTTOMPADDING',(0, 0), (-1, -1), 2),
|
||||||
|
('BACKGROUND', (5, 2), (7, 2), LGRY),
|
||||||
|
]))
|
||||||
|
story.append(totals_tbl)
|
||||||
|
|
||||||
|
# ── Instructions to Vendors ───────────────────────────────────────────────────
|
||||||
|
story.append(Spacer(1, 3*mm))
|
||||||
|
instr_header = [[Paragraph('<b>INSTRUCTIONS TO VENDORS</b>', INSTRH)]]
|
||||||
|
instr_tbl_h = Table(instr_header, colWidths=[TW])
|
||||||
|
instr_tbl_h.setStyle(TableStyle([
|
||||||
|
('BOX', (0, 0), (-1, -1), 0.5, BLK),
|
||||||
|
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 7.5),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
|
||||||
|
]))
|
||||||
|
story.append(instr_tbl_h)
|
||||||
|
|
||||||
|
instructions = [
|
||||||
|
(1, 'Please quote this purchase order no. for further communications and invoices pertaining to this indent.'),
|
||||||
|
(2, 'DELIVERY: Within 4 to 5 days'),
|
||||||
|
(3, "DISPATCH INSTRUCTIONS: To be transported to Navi Mumbai Site Address as above. Freight Supplier's A/C"),
|
||||||
|
(4, 'INSPECTION: NA'),
|
||||||
|
(5, 'TRANSIT INSURANCE: NA'),
|
||||||
|
(6, 'PAYMENT TERMS: Within 30 days from delivery.'),
|
||||||
|
(7, 'We encourage bulk packaging and avoid plastic. No asbestos to be used in any product or packing material.'),
|
||||||
|
]
|
||||||
|
instr_data = [[Paragraph(str(n), VALUEC), Paragraph(txt, VALUE)] for n, txt in instructions]
|
||||||
|
instr_tbl = Table(instr_data, colWidths=[TW*0.05, TW*0.95])
|
||||||
|
instr_tbl.setStyle(TableStyle([
|
||||||
|
('BOX', (0, 0), (-1, -1), 0.5, BLK),
|
||||||
|
('INNERGRID',(0, 0), (-1, -1), 0.3, BLK),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 7.5),
|
||||||
|
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||||
|
('LEFTPADDING', (0, 0), (-1, -1), 3),
|
||||||
|
('RIGHTPADDING', (0, 0), (-1, -1), 3),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
||||||
|
('BOTTOMPADDING',(0, 0), (-1, -1), 2),
|
||||||
|
]))
|
||||||
|
story.append(instr_tbl)
|
||||||
|
|
||||||
|
# ── Signature block ───────────────────────────────────────────────────────────
|
||||||
|
story.append(Spacer(1, 6*mm))
|
||||||
|
sig_data = [
|
||||||
|
[
|
||||||
|
Paragraph('Kaushal Pal Singh', mk_style('SN', fontName='Helvetica', fontSize=7.5, alignment=TA_CENTER)),
|
||||||
|
'',
|
||||||
|
Paragraph(VENDOR, mk_style('SV', fontName='Helvetica-Bold', fontSize=7.5, alignment=TA_CENTER)),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph('Authorized Signatory & Stamp', mk_style('SA', fontName='Helvetica', fontSize=7, alignment=TA_CENTER)),
|
||||||
|
'',
|
||||||
|
Paragraph('Authorized Signatory & Stamp', mk_style('SB', fontName='Helvetica', fontSize=7, alignment=TA_CENTER)),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
Paragraph('For, Pelagia Marine Services Pvt. Ltd.', mk_style('SF', fontName='Helvetica', fontSize=7.5, alignment=TA_CENTER)),
|
||||||
|
'',
|
||||||
|
Paragraph(f'For, {VENDOR}', mk_style('SFF', fontName='Helvetica', fontSize=7.5, alignment=TA_CENTER)),
|
||||||
|
],
|
||||||
|
]
|
||||||
|
sig_tbl = Table(sig_data, colWidths=[TW*0.45, TW*0.10, TW*0.45])
|
||||||
|
sig_tbl.setStyle(TableStyle([
|
||||||
|
('BOX', (0, 0), (0, -1), 0.5, BLK),
|
||||||
|
('BOX', (2, 0), (2, -1), 0.5, BLK),
|
||||||
|
('FONTSIZE', (0, 0), (-1, -1), 7.5),
|
||||||
|
('TOPPADDING', (0, 0), (-1, -1), 2),
|
||||||
|
('BOTTOMPADDING', (0, 0), (-1, -1), 2),
|
||||||
|
]))
|
||||||
|
story.append(sig_tbl)
|
||||||
|
|
||||||
|
# ── Build PDF ─────────────────────────────────────────────────────────────────
|
||||||
|
doc = SimpleDocTemplate(
|
||||||
|
OUT_PDF,
|
||||||
|
pagesize=A4,
|
||||||
|
leftMargin=MARGIN, rightMargin=MARGIN,
|
||||||
|
topMargin=MARGIN, bottomMargin=MARGIN,
|
||||||
|
)
|
||||||
|
doc.build(story)
|
||||||
|
print(f'[PDF] Saved: {OUT_PDF}')
|
||||||
41
inspect_po.py
Normal file
41
inspect_po.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import openpyxl
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
wb = openpyxl.load_workbook(r'C:\Users\shad0w\Documents\src\Peliagia_Portal\Prototype\Sample_PO.xlsx')
|
||||||
|
ws = wb.active
|
||||||
|
print('=== CELL STYLES (key cells) ===')
|
||||||
|
key_cells = ['A1','A4','A5','C5','H5','A13','A15','B15','G15','A27','A37','A38','A39','G38','G39']
|
||||||
|
for coord in key_cells:
|
||||||
|
cell = ws[coord]
|
||||||
|
font = cell.font
|
||||||
|
fill = cell.fill
|
||||||
|
fill_color = None
|
||||||
|
if fill and fill.fill_type and fill.fill_type != 'none':
|
||||||
|
try:
|
||||||
|
fill_color = fill.fgColor.rgb
|
||||||
|
except:
|
||||||
|
fill_color = 'unknown'
|
||||||
|
color_rgb = None
|
||||||
|
try:
|
||||||
|
color_rgb = font.color.rgb
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
print(f'{coord}: bold={font.bold}, size={font.size}, name={font.name}, color={color_rgb}, fill={fill_color}')
|
||||||
|
|
||||||
|
print()
|
||||||
|
print('=== BORDER CHECK (rows 4, 14, 15, 16, 26, 27) ===')
|
||||||
|
for row_n in [4, 14, 15, 16, 26, 27]:
|
||||||
|
for col_n in range(1, 10):
|
||||||
|
coord = get_column_letter(col_n) + str(row_n)
|
||||||
|
cell = ws[coord]
|
||||||
|
b = cell.border
|
||||||
|
sides = []
|
||||||
|
if b.top and b.top.border_style:
|
||||||
|
sides.append('top=' + b.top.border_style)
|
||||||
|
if b.bottom and b.bottom.border_style:
|
||||||
|
sides.append('bot=' + b.bottom.border_style)
|
||||||
|
if b.left and b.left.border_style:
|
||||||
|
sides.append('lft=' + b.left.border_style)
|
||||||
|
if b.right and b.right.border_style:
|
||||||
|
sides.append('rgt=' + b.right.border_style)
|
||||||
|
if sides:
|
||||||
|
print(f' {coord}: {", ".join(sides)}')
|
||||||
Loading…
Add table
Reference in a new issue