diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..a42e5df --- /dev/null +++ b/.claude/settings.local.json @@ -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)" + ] + } +} diff --git a/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx index 6f8c56f..c597a4b 100644 --- a/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx +++ b/App/pelagia-portal/app/(portal)/admin/products/[id]/page.tsx @@ -8,7 +8,7 @@ import { distanceKm, formatDistance } from "@/lib/geo"; import { ToggleProductButton } from "../product-form"; import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; 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"; interface Props { diff --git a/App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx b/App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx deleted file mode 100644 index 16a16c7..0000000 --- a/App/pelagia-portal/app/(portal)/inventory/items/[id]/page.tsx +++ /dev/null @@ -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 { - 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 ( -
- {/* Breadcrumb */} -
- Items - / - {product.name} -
- - {/* Header */} -
-
-
- {product.code} -
-

{product.name}

- {product.description &&

{product.description}

} -
- {minPrice !== null && ( - - )} -
- - {/* Stats */} -
-
-

Vendors

-

{product.vendorPrices.length}

-
-
-

Lowest Price

-

{minPrice !== null ? formatCurrency(minPrice) : "—"}

-
-
-

Highest Price

-

{maxPrice !== null ? formatCurrency(maxPrice) : "—"}

-
-
- - {/* Price chart */} - {priceChartData.length > 1 && } - - {/* Site filter */} - {sites.length > 0 && ( - ({ id: s.id, name: s.name }))} - currentSiteId={siteId ?? null} - baseHref={baseHref} - /> - )} - - {/* Vendors table */} -
-

- Available From - ({enriched.length} vendor{enriched.length !== 1 ? "s" : ""}) - {selectedSite && sorted by distance from {selectedSite.name}} -

- {enriched.length === 0 ? ( -

No vendor pricing on record. Updated automatically when a PO is marked as paid.

- ) : ( - - - - - - - {selectedSite && } - - - - - {enriched.map((vp, idx) => { - const isCheapest = minPrice !== null && vp.price === minPrice && enriched.length > 1; - const isClosest = selectedSite !== null && idx === 0 && vp.distanceKm !== null; - return ( - - - - - {selectedSite && ( - - )} - - - - ); - })} - -
VendorVerifiedPriceDistanceUpdated -
- - {vp.vendor.name} - - - - {vp.vendor.isVerified ? "Verified" : "Unverified"} - - - {formatCurrency(vp.price)} - {isCheapest && !selectedSite && lowest} - - {vp.distanceKm !== null - ? {formatDistance(vp.distanceKm)}{isClosest ? " ★" : ""} - : No location} - {formatDate(vp.updatedAt)} - -
- )} -
- - {/* Stock by site */} - {product.inventory.length > 0 && ( -
-

Stock on Hand

-
- {product.inventory.map((inv) => ( -
- {inv.site.name} - ({inv.site.code}) - {Number(inv.quantity)} units -
- ))} -
-
- )} -
- ); -} diff --git a/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx b/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx index 48c0c45..358d27b 100644 --- a/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx +++ b/App/pelagia-portal/app/(portal)/inventory/items/items-table.tsx @@ -199,13 +199,7 @@ export function ItemsTable({ {isOpen ? : } - e.stopPropagation()} - className="font-medium text-neutral-900 hover:text-primary-600 hover:underline" - > - {item.name} - + {item.name} {item.description && ( {item.description} )} diff --git a/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx b/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx index d80a4e9..f55ea6d 100644 --- a/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx +++ b/App/pelagia-portal/app/(portal)/inventory/vendors/[id]/vendor-items-table.tsx @@ -2,7 +2,6 @@ import { useState, useMemo } from "react"; import { Search, X } from "lucide-react"; -import Link from "next/link"; import { formatCurrency, formatDate } from "@/lib/utils"; import { AddToCartButton } from "@/components/inventory/add-to-cart-button"; @@ -75,9 +74,7 @@ export function VendorItemsTable({ items }: { items: Item[] }) { {filtered.map((item) => ( - - {item.name} - + {item.name} {item.description && ( {item.description} )} diff --git a/App/pelagia-portal/app/(portal)/inventory/items/[id]/site-select.tsx b/App/pelagia-portal/components/inventory/site-select.tsx similarity index 100% rename from App/pelagia-portal/app/(portal)/inventory/items/[id]/site-select.tsx rename to App/pelagia-portal/components/inventory/site-select.tsx diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..7a700ee --- /dev/null +++ b/DESIGN.md @@ -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) diff --git a/Progress/PMS_HNR3_056_2026-27.pdf b/Progress/PMS_HNR3_056_2026-27.pdf new file mode 100644 index 0000000..9d5d652 --- /dev/null +++ b/Progress/PMS_HNR3_056_2026-27.pdf @@ -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#dnIVCr\CQS)\8i,oJOgH%uO$U=hmhCF$u_<(;tGjN_M)3+nT*D@N0[t?,g+(ke*SDF,DZbKoA,P5`SiKQObc_5(+in1V_4t?t8APto56AT)K&'rS3:<=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/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+)[ka=s\eH(nk+_rd%fnFK^!gIV9Y(>pN4,FmG4K]=HiE>A$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,="qgMe-fFd2=R=)YQRH7KimB,ZFVbB?YW<)U*%;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^3d0q"X1,./`(YE3o1C^$lYPDslNfWGHbNWXc87,=GA2aYqrV95`/FqugfBm;^qr8aTnOg4dZB5gfCV3ePht2=g#+5MV8NgTb\\+6!`\cZqApEHG+:/%icN?\"1A5*gVb8[?F'KAu"Dn?Yfd>PN4$JE;(4n8."OO++TpnspMC&,j/WESSf`[j9hf?r.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(^O)E;?[%*RjVE(5TZ$/;gt.5<\Lat7p^fR[eo0[oaL3UA?Y_5Q8qq`B9hO+U/L+5X&nlj&<`$ms7uJn%Vc>21Z@nMq!j`P)i::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]*.Q;lHJ>#JWrd%n8FBM)Q#d%n8FBM)Q#W*tc+_D[g-/8+4Ob+,<)+)^=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,4SO`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[:N3o&%&-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]rq86r?U3j!U'k_\,b'!Z"RVi@:->MD_erA72m2qoLP%TY)GEd=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_RogKh2endstream +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 diff --git a/Progress/PMS_HNR3_056_2026-27.xlsx b/Progress/PMS_HNR3_056_2026-27.xlsx new file mode 100644 index 0000000..ccbf52a Binary files /dev/null and b/Progress/PMS_HNR3_056_2026-27.xlsx differ diff --git a/Progress/PROGRESS.md b/Progress/PROGRESS.md new file mode 100644 index 0000000..e05527f --- /dev/null +++ b/Progress/PROGRESS.md @@ -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) diff --git a/Progress/TODO.md b/Progress/TODO.md new file mode 100644 index 0000000..0f67ba1 --- /dev/null +++ b/Progress/TODO.md @@ -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 \ No newline at end of file diff --git a/Prototype/20260429_005636.jpg b/Prototype/20260429_005636.jpg new file mode 100644 index 0000000..baf7887 Binary files /dev/null and b/Prototype/20260429_005636.jpg differ diff --git a/Prototype/Pelagia Diagrams.html b/Prototype/Pelagia Diagrams.html new file mode 100644 index 0000000..1c53c30 --- /dev/null +++ b/Prototype/Pelagia Diagrams.html @@ -0,0 +1,646 @@ + + + + + +Pelagia Marine Portal — System Diagrams + + + + + + + + +
+ + + diff --git a/Prototype/Pelagia Portal Wireframes.html b/Prototype/Pelagia Portal Wireframes.html new file mode 100644 index 0000000..9559bff --- /dev/null +++ b/Prototype/Pelagia Portal Wireframes.html @@ -0,0 +1,1167 @@ + + + + + +Pelagia Marine Portal – Wireframes + + + + + + + + + + + + +
+ + diff --git a/Prototype/Pelagia System Diagrams v2.html b/Prototype/Pelagia System Diagrams v2.html new file mode 100644 index 0000000..0ebbf61 --- /dev/null +++ b/Prototype/Pelagia System Diagrams v2.html @@ -0,0 +1,656 @@ + + + + + +Pelagia Marine — System Diagrams v2 + + + + + + + + +
+ + + diff --git a/Prototype/Sample_PO.xlsx b/Prototype/Sample_PO.xlsx new file mode 100644 index 0000000..3cba5eb Binary files /dev/null and b/Prototype/Sample_PO.xlsx differ diff --git a/Prototype/design-canvas.jsx b/Prototype/design-canvas.jsx new file mode 100644 index 0000000..9f3fc61 --- /dev/null +++ b/Prototype/design-canvas.jsx @@ -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: +// +// +// +// +// +// + +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 ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// 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 ( +
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// 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 ( +
+
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+ +
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// 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 }) => ( + + ); + + // 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( +
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) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/Prototype/tweaks-panel.jsx b/Prototype/tweaks-panel.jsx new file mode 100644 index 0000000..184b014 --- /dev/null +++ b/Prototype/tweaks-panel.jsx @@ -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 , 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 ( +//
+// Hello +// +// +// setTweak('fontSize', v)} /> +// setTweak('density', v)} /> +// +// setTweak('primaryColor', v)} /> +// setTweak('dark', v)} /> +// +//
+// ); +// } +// +// ───────────────────────────────────────────────────────────────────────────── + +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,"); + 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 ( + <> + +
+
+ {title} + +
+
{children}
+
+ + ); +} + +// ── Layout helpers ────────────────────────────────────────────────────────── + +function TweakSection({ label, children }) { + return ( + <> +
{label}
+ {children} + + ); +} + +function TweakRow({ label, value, children, inline = false }) { + return ( +
+
+ {label} + {value != null && {value}} +
+ {children} +
+ ); +} + +// ── Controls ──────────────────────────────────────────────────────────────── + +function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) { + return ( + + onChange(Number(e.target.value))} /> + + ); +} + +function TweakToggle({ label, value, onChange }) { + return ( +
+
{label}
+ +
+ ); +} + +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 ( + +
+
+ {opts.map((o) => ( + + ))} +
+ + ); +} + +function TweakSelect({ label, value, options, onChange }) { + return ( + + + + ); +} + +function TweakText({ label, value, placeholder, onChange }) { + return ( + + onChange(e.target.value)} /> + + ); +} + +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 ( +
+ {label} + onChange(clamp(Number(e.target.value)))} /> + {unit && {unit}} +
+ ); +} + +function TweakColor({ label, value, onChange }) { + return ( +
+
{label}
+ onChange(e.target.value)} /> +
+ ); +} + +function TweakButton({ label, onClick, secondary = false }) { + return ( + + ); +} + +Object.assign(window, { + useTweaks, TweaksPanel, TweakSection, TweakRow, + TweakSlider, TweakToggle, TweakRadio, TweakSelect, + TweakText, TweakNumber, TweakColor, TweakButton, +}); diff --git a/Spec/01-design-document.md b/Spec/01-design-document.md new file mode 100644 index 0000000..7de700f --- /dev/null +++ b/Spec/01-design-document.md @@ -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? diff --git a/Spec/02-architecture.md b/Spec/02-architecture.md new file mode 100644 index 0000000..37447f8 --- /dev/null +++ b/Spec/02-architecture.md @@ -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> = { + 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 { ... } +``` + +### 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/ │ + │ │ │ + │── PUT /api/files/dev/►│ │ + │ │── write to disk ───────►│ + │ │ │ + │── Server Action: link ───►│ │ + │ { poId, key, meta } │── INSERT PODocument ──►│ (DB) +``` + +Downloads follow the same pattern: `generateDownloadUrl` returns a `/api/files/dev/` 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). diff --git a/Spec/03-open-questions.md b/Spec/03-open-questions.md new file mode 100644 index 0000000..b9e7a20 --- /dev/null +++ b/Spec/03-open-questions.md @@ -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 | — | diff --git a/Spec/TEST_PLAN.md b/Spec/TEST_PLAN.md new file mode 100644 index 0000000..5ea02fe --- /dev/null +++ b/Spec/TEST_PLAN.md @@ -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`. diff --git a/Spec/Untitled.png b/Spec/Untitled.png new file mode 100644 index 0000000..2717db6 Binary files /dev/null and b/Spec/Untitled.png differ diff --git a/design_handoff_pelagia_portal/DESIGN.md b/design_handoff_pelagia_portal/DESIGN.md new file mode 100644 index 0000000..7a700ee --- /dev/null +++ b/design_handoff_pelagia_portal/DESIGN.md @@ -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) diff --git a/design_handoff_pelagia_portal/Pelagia Portal.html b/design_handoff_pelagia_portal/Pelagia Portal.html new file mode 100644 index 0000000..50bf709 --- /dev/null +++ b/design_handoff_pelagia_portal/Pelagia Portal.html @@ -0,0 +1,29 @@ + + + + + +Pelagia Portal + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/design_handoff_pelagia_portal/README.md b/design_handoff_pelagia_portal/README.md new file mode 100644 index 0000000..efbf1ff --- /dev/null +++ b/design_handoff_pelagia_portal/README.md @@ -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: ` open · 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 · : ` 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 " 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 ? 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. diff --git a/design_handoff_pelagia_portal/app.jsx b/design_handoff_pelagia_portal/app.jsx new file mode 100644 index 0000000..eb5c6ef --- /dev/null +++ b/design_handoff_pelagia_portal/app.jsx @@ -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 ( + + ); +}; + +const Header = ({ role, setRole, navigate }) => { + const u = USER_BY_ROLE[role]; + return ( +
+
navigate("dashboard")} style={{ cursor: "pointer" }}> +
+
Pelagia Portal
+
+
+ + + ⌘K +
+
+
+ Role + +
+ +
+
{u.short}
+
+
{u.name}
+
{role.toLowerCase()}
+
+
+
+ ); +}; + +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 setAuthed(true)} />; + + const page = route.page; + const id = route.id; + let body; + switch (page) { + case "dashboard": body = ; break; + case "my-orders": body = ; break; + case "approvals": body = ; break; + case "approval-detail": body = o.id === id) || APPROVAL_QUEUE.find(o => o.id === id) || ORDERS[0]} go={navigate} role={role} />; break; + case "po-detail": body = o.id === id) || ORDERS[0]} go={navigate} role={role} />; break; + case "po-new": body = ; break; + case "payments": body = ; break; + case "history": body = ; break; + case "vendors": body = ; break; + case "vendor-detail": body = ; break; + case "items": body = ; break; + case "item-detail": body = ; break; + case "sites": body = ; break; + case "site-detail": body = ; break; + case "vessels": body = ; break; + case "accounts": body = ; break; + case "users": body = ; break; + case "import-po": body = ; break; + case "cart": body = ; break; + default: body = ; + } + + return ( +
+
+ +
{body}
+
+ ); +}; + +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(); diff --git a/design_handoff_pelagia_portal/components.jsx b/design_handoff_pelagia_portal/components.jsx new file mode 100644 index 0000000..8b387aa --- /dev/null +++ b/design_handoff_pelagia_portal/components.jsx @@ -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: <>, + file: <>, + list: <>, + check: <>, + cart: <>, + box: <>, + truck: <>, + users: <>, + ship: <>, + map: <>, + chart: <>, + user: <>, + settings: <>, + plus: <>, + download: <>, + upload: <>, + search: <>, + chevron: <>, + star: <>, + edit: <>, + trash: <>, + bell: <>, + eye: <>, + refresh: <>, + paperclip: <>, + arrowRight: <>, + pkg: <>, + }; + return ( + + {paths[name] || null} + + ); +}; + +/* ─────────── 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 {label}; + } + return {children}; +}; + +/* ─────────── 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 = "" }) => ( +
+ {(title || action) && ( +
+

{title}

+ {action} +
+ )} +
{children}
+
+); + +/* ─────────── Stat tile ─────────── */ +const Stat = ({ label, value, sub, onClick }) => ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+); + +/* ─────────── Crumbs ─────────── */ +const Crumbs = ({ items }) => ( +
+ {items.map((it, i) => ( + + {i > 0 && /} + {it} + + ))} +
+); + +Object.assign(window, { Icon, Badge, Card, Stat, Crumbs, inr, inrFull, STATUS_LABELS }); diff --git a/design_handoff_pelagia_portal/data.jsx b/design_handoff_pelagia_portal/data.jsx new file mode 100644 index 0000000..885738d --- /dev/null +++ b/design_handoff_pelagia_portal/data.jsx @@ -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 +}); diff --git a/design_handoff_pelagia_portal/pages-1.jsx b/design_handoff_pelagia_portal/pages-1.jsx new file mode 100644 index 0000000..89a1d8b --- /dev/null +++ b/design_handoff_pelagia_portal/pages-1.jsx @@ -0,0 +1,263 @@ +/* Pelagia Portal — page components */ + +const { useState: useS, useEffect: useE, useMemo: useM } = React; + +/* ═══════════════════ LOGIN ═══════════════════ */ +const LoginPage = ({ onLogin }) => ( +
+
+
+
+
+
Pelagia Portal
+
Internal · Purchase Order Management
+
+
+
+
+ + +
+
+ + +
+ +
+ Contact an administrator to request access. +
+
+
+
+); + +/* ═══════════════════ 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 ( + <> +
+
+ +

Good afternoon, Anjali

+
Tuesday, May 12, 2026 · 14:38 IST · 3 vessels active at sea
+
+
+ + +
+
+ + {isMgr && ( + <> +
+ ↑ 2 vs last week} onClick={() => go("approvals")} /> + across 5 vessels} /> + ↓ 8% vs Apr} /> + submit → approve} /> +
+ +
+ ₹ in thousands}> +
+ {SPEND_TREND.map((s, i) => ( +
+ {s.v} +
+ ))} +
+
+ ₹ in thousands}> +
+ {VESSEL_SPEND.map((v, i) => { + const max = Math.max(...VESSEL_SPEND.map(x => x.v)); + return ( +
+
{v.name}
+
+
{v.v}
+
+ ); + })} +
+ +
+ + go("history")} href="#">View history →} flush> + + + + + + + + + {ORDERS.filter(o => ["MGR_APPROVED", "SENT_FOR_PAYMENT", "PAID_DELIVERED", "CLOSED"].includes(o.status)).slice(0, 5).map(o => ( + go("po-detail", o.id)}> + + + + + + + + ))} + +
PO NumberTitleVesselSubmitterApprovedAmount
{o.id}{o.title}{o.vessel}{o.submitter}{o.approved || "—"}{inr(o.amount)}
+
+ + )} + + {isAcct && ( + <> +
+ go("payments")} /> + + + +
+ go("payments")} className="muted" style={{ fontSize: 12 }}>Open queue →}> +
4 POs ready to send for payment. Open the queue to process.
+
+ + )} + + {isSubmitter && ( + <> +
+ go("my-orders")} /> + + + +
+ + + + + {ORDERS.filter(o => o.submitter === "Rajesh Pillai").slice(0, 4).map(o => ( + go("po-detail", o.id)}> + + + + + + + ))} + +
PO NumberTitleVesselStatusAmount
{o.id}{o.title}{o.vessel}{inr(o.amount)}
+
+ + )} + + {isAuditor && ( + <> +
+ go("history")} /> + + + +
+ +
+ + + + +
+
+ + )} + + ); +}; + +/* ═══════════════════ 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 ( + <> +
+
+ +

My Purchase Orders

+
{open.length} open · {pastFull.length} past
+
+ +
+ +

Open orders

+ + {open.length === 0 ?
No open orders.
: ( + + + + + + {open.map(o => ( + + go("po-detail", o.id)}> + + + + + + + + {o.managerNote && ( + + )} + + ))} + +
PO NumberTitleVesselStatusUpdatedAmount
{o.id}{o.title}{o.vessel}{o.submitted || o.created || "—"}{inr(o.amount)}
+
+ +
+ Manager note · Anjali Krishnan: {o.managerNote} +
+
+
+ )} +
+ +

Past orders

+ + + + + + + {pastFull.map(o => ( + go("po-detail", o.id)}> + + + + + + + + ))} + +
PO NumberTitleVesselStatusClosed/CompletedAmount
{o.id}{o.title}{o.vessel}{o.closed || o.paid || o.approved || o.rejected || "—"}{inr(o.amount)}
+
+ + ); +}; + +Object.assign(window, { LoginPage, Dashboard, MyOrders }); diff --git a/design_handoff_pelagia_portal/pages-2.jsx b/design_handoff_pelagia_portal/pages-2.jsx new file mode 100644 index 0000000..e60296c --- /dev/null +++ b/design_handoff_pelagia_portal/pages-2.jsx @@ -0,0 +1,502 @@ +/* Pelagia Portal — Approvals, PO Detail, New PO */ + +/* ═══════════════════ APPROVAL QUEUE ═══════════════════ */ +const ApprovalsPage = ({ go }) => ( + <> +
+
+ +

Approval queue + · {APPROVAL_QUEUE.length} pending +

+
POs awaiting manager decision · oldest first
+
+
+ +
+
+ + +
+ + + + Sorted by submitted date +
+ + + + + + + + + + {APPROVAL_QUEUE.map(o => ( + go("approval-detail", o.id)}> + + + + + + + + + ))} + +
PO NumberTitleSubmitterVesselSubmittedAmount
{o.id}{o.title}{o.submitter}{o.vessel}{o.submitted}{inr(o.amount)} + Review → +
+
+ +); + +/* ═══════════════════ PO DETAIL (shared) ═══════════════════ */ +const ItemsTable = ({ items }) => { + if (!items?.length) return
No line items.
; + 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 ( + <> + + + + + + + + + {items.map((i, idx) => ( + + + + + + + + + + ))} + +
ItemDescriptionQtyUnitUnit PriceGSTTotal
{i.name}{i.desc}{i.qty}{i.unit}{inrFull(i.price)}{i.gst}%{inrFull(i.qty * i.price * (1 + i.gst/100))}
+
+
+
TAXABLE
+
{inrFull(taxable)}
+
+
+
GST
+
{inrFull(gst)}
+
+
+
GRAND TOTAL
+
{inrFull(taxable + gst)}
+
+
+ + ); +}; + +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 ( + <> +
+
+ +

{order.title}

+
+ Created by {order.submitter} · {order.created || order.submitted} +
+
+
+ + {showEdit && } + {!isApproval && order.status === "DRAFT" && } +
+
+ +
+
+
{order.id}
+ + {order.project && {order.project}} +
+
+
GRAND TOTAL
+
{inr(order.amount)}
+
+
+ + {order.managerNote && ( +
+ +
+ Manager note · Anjali Krishnan: {order.managerNote} +
+
+ )} + +
+ {/* MAIN */} +
+ {isApproval && ( + + + + )} + + {showVendorPicker && ( + +
+ The manager has requested that a vendor be selected before approval. +
+
+
+ + +
+ +
+
+ )} + +

Summary

+ +
+
Vessel
{order.vessel}
+
Account
{order.account}
+
Vendor
{order.vendor || Not yet assigned}
+
Project code
{order.project ? {order.project} : }
+
Date required
{order.required || "—"}
+
Currency
INR (₹)
+
+
+ +

Line items

+ + + + +

Terms & conditions

+ +
+
Delivery
{order.terms?.delivery || ORDERS[0].terms.delivery}
+
Dispatch
{order.terms?.dispatch || ORDERS[0].terms.dispatch}
+
Inspection
{order.terms?.inspection || ORDERS[0].terms.inspection}
+
Transit insurance
{order.terms?.insurance || ORDERS[0].terms.insurance}
+
Payment terms
{order.terms?.payment || ORDERS[0].terms.payment}
+
Other
{order.terms?.others || ORDERS[0].terms.others}
+
+
+ +

Documents

+ +
+ {(order.docs || ORDERS[0].docs).map((d, i) => ( +
+ + {d.name} + {d.size} + + +
+ ))} +
+
+ + {showReceipt && ( + <> +

Confirm receipt

+ +
+ Goods have been marked as paid and dispatched. Upload the delivery receipt to close this PO. +
+
+
+   Drop delivery receipt file or browse +
+ + +
+
+ + )} +
+ + {/* SIDEBAR */} +
+ +
+
Created
{order.created || "May 10, 2026"}
+
Submitted
{order.submitted || "—"}
+
Approved
{order.approved || "—"}
+
Paid
{order.paid || "—"}
+
Closed
{order.closed || "—"}
+
+
+ +
+ + +
+ {(order.audit || ORDERS[0].audit).map((a, i) => ( +
+
+
+
{a.who}
+
{a.what}
+
+
{a.when.split(" · ")[1] || a.when}
+
+ ))} +
+ +
+
+ + ); +}; + +const ApprovalActions = () => { + const [mode, setMode] = useS(null); // null | edits | reject | note | vendor + + if (mode === null) { + return ( +
+ + + + + + +
+ ); + } + + 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 ( +
+
+
+
{title}
+
{sub}
+
+ +
+ +
+ +
+
+ ); +}; + +/* ═══════════════════ 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 ( + <> +
+
+ +

New purchase order

+
Fill the four sections below. You can save as draft at any time.
+
+
+ + + +
+
+ +

1 · Header

+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +

2 · Line items

+ + + + + + + + + + + + + + + + {rows.map((r, idx) => ( + + + + + + + + + + + + {suggestIdx === idx && r.name && ( + + + + )} + + ))} + +
NameDescriptionQtyUnitUnit priceGST %Total
+ updateRow(idx, "name", e.target.value)} + onFocus={() => setSuggestIdx(idx)} + onBlur={() => setTimeout(() => setSuggestIdx(-1), 200)} /> + + updateRow(idx, "desc", e.target.value)} /> + updateRow(idx, "qty", e.target.value)} /> updateRow(idx, "unit", e.target.value)} /> updateRow(idx, "price", e.target.value)} /> updateRow(idx, "gst", e.target.value)} />{inr((Number(r.qty)||0) * (Number(r.price)||0) * (1 + (Number(r.gst)||0)/100))}
+
+ Last seen at: + · Mahalakshmi Marine Stores ₹4,250 + · Coastline Engineering ₹4,380 + · Konark Industrial Spares ₹4,520 +
+
+
+ + Tip: start typing a product name to see vendor price hints +
+
+
+
TAXABLE
+
{inrFull(taxable)}
+
+
+
GST
+
{inrFull(gst)}
+
+
+
GRAND TOTAL
+
{inrFull(taxable + gst)}
+
+
+
+ +

3 · Terms & conditions

+ +
+ {[ + ["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]) => ( +
+ + +
+ ))} +
+
+ +

4 · Documents

+ +
+   Drop files here or browse +
Quotations, technical sheets, requisitions. Max 25 MB per file.
+
+
+ {[ + ["Vendor Quotation Q-2841.pdf", "412 KB"], + ["Engine Inspection Report.pdf", "1.8 MB"], + ].map(([n, s]) => ( +
+ + {n} + {s} + + +
+ ))} +
+
+ +
+ + +
+ + ); +}; + +Object.assign(window, { ApprovalsPage, PODetailBase, NewPOPage }); diff --git a/design_handoff_pelagia_portal/pages-3.jsx b/design_handoff_pelagia_portal/pages-3.jsx new file mode 100644 index 0000000..cf5598f --- /dev/null +++ b/design_handoff_pelagia_portal/pages-3.jsx @@ -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 ( + <> +
+
+ +

Payment queue

+
{ready.length} ready for payment · {sent.length} awaiting confirmation
+
+
+ +

Ready for payment

+
+ {ready.map(o => ( +
+
+
+
{o.id}
+
{o.title}
+
+ +
+
+
Vessel
{o.vessel}
+
Vendor
{o.vendor || }
+
Submitter
{o.submitter}
+
Approved
{o.approved || "—"}
+
+
{inrFull(o.amount)}
+
+ + +
+
+ ))} +
+ +

Processing — awaiting confirmation

+
+ {sent.map(o => ( +
+
+
+
{o.id}
+
{o.title}
+
+ +
+
+
Vessel
{o.vessel}
+
Vendor
{o.vendor}
+
Submitter
{o.submitter}
+
Sent on
May 09, 2026
+
+
{inrFull(o.amount)}
+
+ + +
+
+ ))} + {sent.length === 0 &&
Nothing currently processing.
} +
+ + ); +}; + +/* ═══════════════════ HISTORY ═══════════════════ */ +const HistoryPage = ({ go }) => ( + <> +
+
+ +

Order history

+
All POs across all statuses · apply filters then export
+
+
+ + +
+
+ +
+
+ From + +
+
+ To + +
+ + + + {ORDERS.length} matching · export uses current filters +
+ + + + + + + + + + + {ORDERS.map(o => ( + go("po-detail", o.id)}> + + + + + + + + + ))} + +
PO NumberTitleVesselSubmitterStatusCreatedAmount
{o.id}{o.title}{o.vessel}{o.submitter}{o.submitted || o.created || "—"}{inr(o.amount)}
+
+ +); + +/* ═══════════════════ VENDOR REGISTRY ═══════════════════ */ +const VendorsPage = ({ go }) => { + const [showAdd, setShowAdd] = useS(false); + return ( + <> +
+
+ +

Vendor registry

+
{VENDORS.length} vendors · {VENDORS.filter(v => v.verified).length} verified via GSTIN lookup
+
+ +
+ + {showAdd && setShowAdd(false)} />} + + + + + + + + + + + {VENDORS.map(v => ( + go("vendor-detail", v.id)}> + + + + + + + + + ))} + +
Vendor IDNameContactItemsVerifiedStatus
{v.vid || Pending} +
{v.name}
+
{v.city} · GSTIN {v.gstin}
+
+
{v.contact}
+
{v.email}
+
{[42, 18, 64, 7, 22, 31][VENDORS.indexOf(v)]}{v.verified + ? Verified + : } + {v.active ? "Active" : "Inactive"} + +
+
+ + ); +}; + +const AddVendorPanel = ({ onClose }) => { + const [step, setStep] = useS(1); // 1 type GSTIN, 2 captcha, 3 verified + + return ( + Close} className="action-panel" style={{ marginBottom: 18 }}> +
+
+ +
Look up the vendor's GSTIN to auto-fill name, address, and pincode. Manual entry is allowed if needed.
+
+ +
+
+ + +
+ {step === 1 && ( + + )} + {step >= 2 && ( +
+ +
+
K4G7AP
+ + +
+
+ )} + {step >= 2 && ( + + )} +
+ + {step === 3 && ( + <> +
+ +
Verified. Legal name, address, and pincode have been pulled from the GST portal.
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ + )} +
+
+ ); +}; + +/* ═══════════════════ VENDOR DETAIL ═══════════════════ */ +const VendorDetailPage = ({ go, id }) => { + const v = VENDORS.find(x => x.id === id) || VENDORS[0]; + return ( + <> +
+
+ +

{v.name}

+
+ {v.vid ? <>{v.vid}· : null} + {v.verified && <> GSTIN verified·} + {v.active ? "Active" : "Inactive"} +
+
+ +
+ +
+
+

Items supplied

+ + + + + {PRODUCTS.slice(0, 5).map(p => ( + go("item-detail", p.id)}> + + + + + + + ))} + +
CodeProductLast quotedLast updated
{p.code}{p.name}{inrFull(p.lastPrice)}{p.updated}
+
+ +

Recent purchase orders

+ + + + + {ORDERS.slice(0, 6).map(o => ( + go("po-detail", o.id)}> + + + + + + + ))} + +
PO NumberStatusVesselCreatedAmount
{o.id}{o.vessel}{o.submitted || "—"}{inr(o.amount)}
+
+
+ +
+ +
+
GSTIN
{v.gstin}
+
Pincode
{v.pin}
+
City
{v.city}
+
Contact
{v.contact}
+
Mobile
{v.phone}
+
Email
{v.email}
+
+
+
+ +
+ 48/2A Bristow Road, Willingdon Island,
+ Kochi, Kerala — {v.pin} +
+
+
+
9.9489° N · 76.2622° E
+
+ +
+
+ + ); +}; + +/* ═══════════════════ ITEMS CATALOGUE ═══════════════════ */ +const ItemsPage = ({ go }) => ( + <> +
+
+ +

Item catalogue

+
{PRODUCTS.length} products · auto-synced when POs are marked as paid
+
+ +
+ +
+ +
Items are added automatically when a PO is marked as paid. Manual entry is reserved for ADMIN.
+
+ + + + + + + + + + + {PRODUCTS.map(p => ( + go("item-detail", p.id)}> + + + + + + + + + + ))} + +
CodeNameDescriptionVendorsLast priceLast vendorUpdated
{p.code}{p.name}{p.desc}{p.vendors}{inr(p.lastPrice)}{p.lastVendor}{p.updated}{p.active ? "Active" : "Inactive"}
+
+ +); + +/* ═══════════════════ 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 ( + <> +
+
+ +

{p.name}

+
+ {p.code}· + Active· + {p.desc} +
+
+
+ + +
+
+ +
+ + Anchor Supply Traders} /> + Konark Industrial Spares} /> + of 4 sites} /> +
+ +
+ Unit price · INR}> +
+ {vendors.map((v, i) => ( +
+ {inr(v.price)} +
+ ))} +
+
+ + +
+ Cochin Port Depot · 18 ea + Chennai South Dock · 6 ea + Visakhapatnam Yard · 14 ea + Mumbai BPX Office · 0 ea +
+
+
Inventory is updated when consumption is logged at the site. Click a chip to open site detail.
+ +
+ +
+

Vendor pricing

+
+ Sort by distance from + +
+
+ + + + + + + + {siteFilter && } + + + + + + {vendors.map((v, i) => ( + + + + + {siteFilter && } + + + + ))} + +
VendorVerifiedUnit priceDistanceLast updated
+ {v.closest && siteFilter && } + {v.vendor} + {v.verified ? Verified : }{inrFull(v.price)}{v.distance} km{v.updated} + +
+
+ + ); +}; + +Object.assign(window, { PaymentsPage, HistoryPage, VendorsPage, VendorDetailPage, ItemsPage, ItemDetailPage }); diff --git a/design_handoff_pelagia_portal/pages-4.jsx b/design_handoff_pelagia_portal/pages-4.jsx new file mode 100644 index 0000000..3bb444f --- /dev/null +++ b/design_handoff_pelagia_portal/pages-4.jsx @@ -0,0 +1,451 @@ +/* Pelagia Portal — Sites, Cart, Vessels, Accounts, Users, Import PO */ + +/* ═══════════════════ SITES ═══════════════════ */ +const SitesPage = ({ go }) => ( + <> +
+
+ +

Sites

+
{SITES.length} ports, depots, and offices that hold inventory
+
+ +
+ + + + + + + + + + + + {SITES.map(s => ( + go("site-detail", s.id)}> + + + + + + + + + + ))} + +
NameCodeAddressVesselsItemsLocationStatus
{s.name}{s.code}{s.address}{s.vessels}{s.items}{["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)]}Active
+
+ +); + +/* ═══════════════════ 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 ( + <> +
+
+ +

{s.name}

+
+ {s.code}· + {s.address}· + 9.9489° N · 76.2622° E +
+
+ +
+ +
+ + + last calculated 12:30} /> + across all items} /> +
+ +
+ Quantity on hand}> +
+ {SITE_INVENTORY.map((it, i) => ( +
+
{it.name}
+
+
{it.qty} ea
+
+ ))} +
+ + + Daily draw-down}> +
+ + {/* grid lines */} + {[0, 1, 2, 3].map(i => ( + + ))} + {[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 ( + + ); + })} + +
+ {SITE_INVENTORY.slice(0, 5).map((it, i) => ( + + + {it.name.split("—")[0].trim().split(" ").slice(0,2).join(" ")} + + ))} +
+
+
+
+ +
+
+

Inventory

+ + + + + {SITE_INVENTORY.map((it, i) => ( + go("item-detail", "p1")}> + + + + + + ))} + +
ProductQty on handLast updated
{it.name}{it.qty} ea{it.updated}
+
+ +

Recent POs for this site

+ + + + + {ORDERS.slice(0, 5).map(o => ( + go("po-detail", o.id)}> + + + + + + + ))} + +
PO NumberStatusVendorCreatedAmount
{o.id}{o.vendor || "—"}{o.submitted || "—"}{inr(o.amount)}
+
+
+ +
+ +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+
+ +
+ + +
+ MV Pelagia Voyager + MV Coral Crescent +
+
+
+
+ + ); +}; + +/* ═══════════════════ VESSELS ═══════════════════ */ +const VesselsPage = () => ( + <> +
+
+ +

Vessels

+
{VESSELS.length} vessels in service
+
+ +
+ + + + + {VESSELS.map(v => ( + + + + + + + + ))} + +
NameIMO NumberAssigned siteStatus
{v.name}{v.imo}{["Cochin Port Depot","Mumbai BPX Office","Visakhapatnam Yard","Chennai South Dock","Cochin Port Depot"][VESSELS.indexOf(v)]}Active
+
+ +); + +/* ═══════════════════ ACCOUNTS ═══════════════════ */ +const AccountsPage = () => ( + <> +
+
+ +

Accounts / Cost centres

+
{ACCOUNTS.length} active cost centres
+
+ +
+ + + + + {ACCOUNTS.map(a => ( + + + + + + + + ))} + +
CodeNameDescriptionStatus
{a.code}{a.name}{a.desc}Active
+
+ +); + +/* ═══════════════════ USERS ═══════════════════ */ +const UsersPage = () => ( + <> +
+
+ +

Users

+
{USERS.length} active users · 7 roles
+
+ +
+ + + + + {USERS.map(u => ( + + + + + + + + + + ))} + +
Employee IDNameEmailRoleStatusCreated
{u.emp}{u.name}{u.email}{u.role}Active{u.created}
+
+ +); + +/* ═══════════════════ 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 ( + <> +
+
+ +

Cart

+
{items.length} items · saved locally to this device
+
+
+ + +
+
+ +
+ + {items.length === 0 ?
Cart is empty. Add items from the Item catalogue.
: ( + <> +
+
Item · Vendor
+
Unit price
+
Quantity
+
Subtotal
+
+
+ {items.map(it => ( +
+
+
{it.name}
+
{it.desc} · {it.vendor}
+
+
{inrFull(it.price)}
+
+ 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 }} /> +
+
{inrFull(it.price * it.qty)}
+ +
+ ))} + + )} +
+ +
+ +
+
Taxable
{inrFull(taxable)}
+
GST
{inrFull(gst)}
+
+
+
+ Grand total + {inrFull(taxable + gst)} +
+ + +
+ + +
+ +
+
+ The selected site pre-fills as place of delivery on the new PO. +
+
+
+
+ + ); +}; + +/* ═══════════════════ IMPORT PO ═══════════════════ */ +const ImportPOPage = ({ go }) => ( + <> +
+
+ +

Import PO from Excel

+
Upload a file in Pelagia's standard PO template format
+
+
+ +
+ +
+   Drop .xlsx file here or browse +
Template downloadable from /docs/po-template.xlsx
+
+
+ + Q-Mahalakshmi-2841.xlsx · 84 KB · parsed + + 5 line items detected +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +

3 · Extracted line items — review before saving

+ + + + + + + + + + {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) => ( + + + + + + + + + + ))} + +
ItemDescriptionQtyUnitUnit priceGSTTotal
{i.name}{i.desc}{i.qty}{i.unit}{inrFull(i.price)}{i.gst}%{inrFull(i.qty * i.price * (1 + i.gst / 100))}
+
+ +
+ + +
+ +); + +Object.assign(window, { SitesPage, SiteDetailPage, VesselsPage, AccountsPage, UsersPage, CartPage, ImportPOPage }); diff --git a/design_handoff_pelagia_portal/styles.css b/design_handoff_pelagia_portal/styles.css new file mode 100644 index 0000000..1b4878c --- /dev/null +++ b/design_handoff_pelagia_portal/styles.css @@ -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); } diff --git a/generate_po.py b/generate_po.py new file mode 100644 index 0000000..a46e090 --- /dev/null +++ b/generate_po.py @@ -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('Purchase Order No:', LABEL), + Paragraph(PO_NO, BOLDVAL), + Paragraph('Date:', LABEL), + Paragraph(po_date_str, VALUEC), + ], + [ + Paragraph('Performa Invoice / Quotation No:', LABEL), + Paragraph('Verbal', VALUEC), + Paragraph('P I / Quotation Date:', 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('Vessel Owner Name', LABEL), + Paragraph('Pelagia Marine Services Pvt. Ltd.', VALUE), + Paragraph('Budget head', LABEL), + Paragraph('700203', VALUEC), + Paragraph('Requested By', LABEL), + Paragraph('Kaushal Pal Singh', VALUE), + ], + [ + Paragraph('Vessel/Office Requisition No.', LABEL), + Paragraph('', VALUE), + Paragraph('Reqn. Date', LABEL), + Paragraph('', VALUEC), + Paragraph('Approved By', 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('Place of Delivery', LABEL), + Paragraph( + 'Pelagia Marine Services Pvt. Ltd. Reti Bundar Near Konkan Bhavan, ' + 'CBD Belapur, Navi Mumbai - 400614', VALUE), + ], + [ + Paragraph('Invoice Details', LABEL), + Paragraph( + 'Pelagia Marine Services Pvt Ltd, 409-410, ZION, Plot 273, Sector-10, ' + 'Kharghar, Navi Mumbai- 410210 (MH)
' + '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('Vendor Name & Address', 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('Contact Person / Mobile', 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('S.N.', BOLDVAL), + Paragraph('Description', BOLDVAL), + Paragraph('Unit', BOLDVAL), + Paragraph('Qty', BOLDVAL), + Paragraph('Unit Price', BOLDVAL), + Paragraph('Taxable Cost', BOLDVAL), + Paragraph('GST %', BOLDVAL), + Paragraph('Total Cost', 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('Total Taxable Value', LABEL), '', Paragraph(f'{TAXABLE:,.2f}', VALUER)], + ['', '', '', '', '', Paragraph('GST (18%)', LABEL), '', Paragraph(f'{GST_AMT:,.2f}', VALUER)], + [ + Paragraph(f'Total Qty: {QTY:,} Ltr', VALUE), + '', '', '', + '', + Paragraph('GRAND TOTAL', LABEL), + '', + Paragraph(f'{GRAND_TOTAL:,.2f}', 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('INSTRUCTIONS TO VENDORS', 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}') diff --git a/inspect_po.py b/inspect_po.py new file mode 100644 index 0000000..c1f5727 --- /dev/null +++ b/inspect_po.py @@ -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)}')