feat(mobile): manager approval queue and PO review on small screens

Adds a responsive mobile experience scoped to MANAGER and SUPERUSER roles:

Layout:
- Sidebar hidden on small screens (md: breakpoint)
- New MobileHeader (logo + notification bell + sign out) visible on mobile
- New MobileBottomNav (Approvals + Profile tabs) pinned to bottom on mobile
- New DesktopRequired overlay shown to all other roles on small screens —
  a fixed full-screen message directing them to use a desktop browser

Approvals queue:
- Desktop: existing table layout (hidden md:block)
- Mobile: tap-to-review card stack (md:hidden) — shows PO number, title,
  submitter, cost centre, amount, and a full-width Review button

PO approval detail:
- ManagerEditPoForm (direct field editing) hidden on mobile; still available
  on desktop (direct edits not required per spec)
- ApprovalActions buttons stack full-width on mobile, row on sm+
- Paddings reduced on small screens throughout

PO detail component:
- Order Details and Vendor grids switch from grid-cols-2 → grid-cols-1
  on small screens (sm:grid-cols-2 restores two columns at 640px+)
- Section padding reduced on mobile (p-3 md:p-6, p-4 md:p-6)
- Line items table already had overflow-x-auto — no change needed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hardik 2026-05-16 21:27:43 +05:30
parent 3646af5c64
commit cfb16600d7
8 changed files with 232 additions and 62 deletions

View file

@ -42,7 +42,7 @@ export function ApprovalActions({
}
return (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
<h3 className="text-base font-semibold text-neutral-900 mb-4">Decision</h3>
{(activeAction === "reject" || activeAction === "request_edits" || activeAction === "approve_note") && (
@ -70,14 +70,14 @@ export function ApprovalActions({
<p className="mb-4 text-sm text-danger-700 bg-danger-50 rounded-lg px-3 py-2">{error}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<div className="flex flex-col sm:flex-row flex-wrap items-stretch sm:items-center gap-2 sm:gap-3">
<button
onClick={() => {
setActiveAction("reject");
if (activeAction === "reject") dispatch("reject", true);
}}
disabled={!!pending}
className="rounded-lg border border-danger bg-danger-50 px-4 py-2.5 text-sm font-medium text-danger-700 hover:bg-danger-100 disabled:opacity-60 transition-colors"
className="w-full sm:w-auto rounded-lg border border-danger bg-danger-50 px-4 py-2.5 text-sm font-medium text-danger-700 hover:bg-danger-100 disabled:opacity-60 transition-colors"
>
{pending === "reject" ? "Rejecting…" : activeAction === "reject" ? "Confirm Reject" : "Reject"}
</button>
@ -87,14 +87,14 @@ export function ApprovalActions({
if (activeAction === "request_edits") dispatch("request_edits", true);
}}
disabled={!!pending}
className="rounded-lg border border-warning bg-warning-50 px-4 py-2.5 text-sm font-medium text-warning-700 hover:bg-warning-100 disabled:opacity-60 transition-colors"
className="w-full sm:w-auto rounded-lg border border-warning bg-warning-50 px-4 py-2.5 text-sm font-medium text-warning-700 hover:bg-warning-100 disabled:opacity-60 transition-colors"
>
{pending === "request_edits" ? "Sending…" : activeAction === "request_edits" ? "Send Edit Request" : "Request Edits"}
</button>
<button
onClick={() => dispatch("request_vendor_id")}
disabled={!!pending}
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors"
className="w-full sm:w-auto rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors"
>
{pending === "request_vendor_id" ? "Sending…" : "Request Vendor ID"}
</button>
@ -104,14 +104,14 @@ export function ApprovalActions({
if (activeAction === "approve_note") dispatch("approve_note");
}}
disabled={!!pending}
className="rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors"
className="w-full sm:w-auto rounded-lg border border-neutral-300 bg-white px-4 py-2.5 text-sm font-medium text-neutral-700 hover:bg-neutral-50 disabled:opacity-60 transition-colors"
>
{pending === "approve_note" ? "Approving…" : activeAction === "approve_note" ? "Confirm Approve with Remarks" : "Approve with Remarks"}
</button>
<button
onClick={() => dispatch("approve")}
disabled={!!pending}
className="rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
className="w-full sm:w-auto rounded-lg bg-success px-4 py-2.5 text-sm font-semibold text-white hover:opacity-90 disabled:opacity-60 transition-opacity"
>
{pending === "approve" ? "Approving…" : "Approve"}
</button>

View file

@ -64,10 +64,10 @@ export default async function ApprovalDetailPage({ params }: Props) {
return (
<div className="max-w-6xl">
<div className="mb-6 flex items-center justify-between">
<div className="mb-4 md:mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-neutral-900">Review Purchase Order</h1>
<p className="mt-1 text-sm text-neutral-500">{po.poNumber} {po.title}</p>
<h1 className="text-lg md:text-2xl font-semibold text-neutral-900">Review Purchase Order</h1>
<p className="mt-0.5 text-sm text-neutral-500">{po.poNumber} {po.title}</p>
</div>
</div>
@ -75,25 +75,28 @@ export default async function ApprovalDetailPage({ params }: Props) {
<div className="mb-4 rounded-lg border border-danger-100 bg-danger-50 px-4 py-3">
<p className="text-sm font-medium text-danger-700">Vendor required before approval</p>
<p className="text-sm text-danger-600 mt-0.5">
This PO has no vendor assigned. Use &ldquo;Request Vendor ID&rdquo; to route it for vendor selection, or assign a vendor via the Edit PO form.
This PO has no vendor assigned. Use &ldquo;Request Vendor ID&rdquo; to route it for vendor selection, or assign a vendor via the Edit PO form on desktop.
</p>
</div>
)}
<PoDetail po={po} currentUserId={session.user.id} currentRole={session.user.role} readOnly />
{/* Direct field editing is desktop-only */}
<div className="hidden md:block">
<ManagerEditPoForm
po={serializedPo}
vessels={vessels}
accounts={accounts}
vendors={vendors}
/>
</div>
<div className="mt-6">
<div className="mt-4 md:mt-6">
{hasSignature ? (
<ApprovalActions poId={po.id} poStatus={po.status} />
) : (
<div className="rounded-lg border border-warning-200 bg-warning-50 p-5 flex items-start gap-3">
<div className="rounded-lg border border-warning-200 bg-warning-50 p-4 md:p-5 flex items-start gap-3">
<span className="text-warning-500 text-xl leading-none mt-0.5"></span>
<div>
<p className="text-sm font-semibold text-warning-800">Signature required to approve POs</p>

View file

@ -68,7 +68,9 @@ export default async function ApprovalsPage({ searchParams }: Props) {
<p className="text-neutral-500">No purchase orders awaiting approval.</p>
</div>
) : (
<div className="rounded-lg border border-neutral-200 bg-white overflow-hidden">
<>
{/* ── Desktop table ─────────────────────────────────────────── */}
<div className="hidden md:block rounded-lg border border-neutral-200 bg-white overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
@ -104,6 +106,37 @@ export default async function ApprovalsPage({ searchParams }: Props) {
</tbody>
</table>
</div>
{/* ── Mobile cards ──────────────────────────────────────────── */}
<div className="md:hidden space-y-3">
{pending.map((po) => (
<Link key={po.id} href={`/approvals/${po.id}`} className="block">
<div className="rounded-xl border border-neutral-200 bg-white p-4 shadow-sm active:bg-neutral-50 transition-colors">
<div className="flex items-start justify-between gap-2 mb-1">
<span className="font-mono text-xs text-neutral-500">{po.poNumber}</span>
<span className="text-xs text-neutral-400 shrink-0">
{po.submittedAt ? formatDate(po.submittedAt) : "—"}
</span>
</div>
<p className="font-semibold text-neutral-900 leading-snug mb-2">{po.title}</p>
<div className="flex items-center justify-between text-sm">
<span className="text-neutral-500 truncate max-w-[55%]">
{po.submitter.name} · {po.vessel.name}
</span>
<span className="font-mono font-semibold text-neutral-900 shrink-0">
{formatCurrency(Number(po.totalAmount), po.currency)}
</span>
</div>
<div className="mt-3">
<span className="block w-full rounded-lg bg-primary-600 py-2 text-center text-sm font-semibold text-white">
Review
</span>
</div>
</div>
</Link>
))}
</div>
</>
)}
</div>
);

View file

@ -3,6 +3,12 @@ import { db } from "@/lib/db";
import { redirect } from "next/navigation";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { MobileHeader } from "@/components/layout/mobile-header";
import { MobileBottomNav } from "@/components/layout/mobile-bottom-nav";
import { DesktopRequired } from "@/components/layout/desktop-required";
// Roles that have a useful mobile experience (approval queue + PO review)
const MOBILE_ROLES = ["MANAGER", "SUPERUSER"] as const;
export default async function PortalLayout({
children,
@ -12,6 +18,8 @@ export default async function PortalLayout({
const session = await auth();
if (!session?.user) redirect("/login");
const hasMobile = (MOBILE_ROLES as readonly string[]).includes(session.user.role);
const [notifications, unreadCount] = await Promise.all([
db.notification.findMany({
where: { userId: session.user.id },
@ -32,14 +40,38 @@ export default async function PortalLayout({
return (
<div className="flex h-screen overflow-hidden bg-neutral-50">
{/* Desktop sidebar — hidden on small screens */}
<div className="hidden md:flex">
<Sidebar userRole={session.user.role} />
</div>
<div className="flex flex-1 flex-col overflow-hidden">
{/* Mobile top bar */}
<MobileHeader
user={session.user}
initialUnreadCount={unreadCount}
initialNotifications={serialisedNotifications}
/>
{/* Desktop top bar — hidden on small screens */}
<div className="hidden md:block">
<Header
user={session.user}
initialUnreadCount={unreadCount}
initialNotifications={serialisedNotifications}
/>
<main className="flex-1 overflow-y-auto p-6">{children}</main>
</div>
{/* Page content — add bottom padding on mobile to clear the bottom nav */}
<main className="flex-1 overflow-y-auto p-4 md:p-6 pb-20 md:pb-6">
{children}
</main>
{/* Mobile bottom nav — managers/superusers only */}
{hasMobile && <MobileBottomNav />}
{/* Full-screen overlay for roles without a mobile experience */}
{!hasMobile && <DesktopRequired />}
</div>
</div>
);

View file

@ -0,0 +1,19 @@
import { Monitor } from "lucide-react";
export function DesktopRequired() {
return (
<div className="md:hidden fixed inset-0 z-40 flex flex-col items-center justify-center bg-white px-8 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary-50 mb-5">
<Monitor className="h-8 w-8 text-primary-600" />
</div>
<h2 className="text-xl font-semibold text-neutral-900 mb-2">Desktop Required</h2>
<p className="text-sm text-neutral-500 max-w-xs leading-relaxed">
PPMS is designed for use on a desktop or laptop browser. Please switch to a larger screen to
access all features.
</p>
<p className="mt-4 text-xs text-neutral-400 italic">
PMS it runs the ship, from the right seat.
</p>
</div>
);
}

View file

@ -0,0 +1,36 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CheckSquare, UserCircle } from "lucide-react";
import { cn } from "@/lib/utils";
const TABS = [
{ href: "/approvals", label: "Approvals", icon: CheckSquare },
{ href: "/profile", label: "Profile", icon: UserCircle },
];
export function MobileBottomNav() {
const pathname = usePathname();
return (
<nav className="md:hidden flex h-16 shrink-0 items-stretch border-t border-neutral-200 bg-white">
{TABS.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Link
key={href}
href={href}
className={cn(
"flex flex-1 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors",
active ? "text-primary-600" : "text-neutral-500 hover:text-neutral-700"
)}
>
<Icon className={cn("h-5 w-5", active ? "stroke-[2.5]" : "")} />
{label}
</Link>
);
})}
</nav>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import { signOut } from "next-auth/react";
import { Anchor, LogOut } from "lucide-react";
import { NotificationBell } from "./notification-bell";
import type { Role } from "@prisma/client";
interface NotificationItem {
id: string;
body: string;
link: string | null;
isRead: boolean;
sentAt: string;
poId: string | null;
}
interface MobileHeaderProps {
user: { name: string; role: Role };
initialUnreadCount: number;
initialNotifications: NotificationItem[];
}
export function MobileHeader({ user, initialUnreadCount, initialNotifications }: MobileHeaderProps) {
return (
<header className="flex h-14 items-center justify-between border-b border-neutral-200 bg-white px-4 md:hidden">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary-600">
<Anchor className="h-3.5 w-3.5 text-white" />
</div>
<span className="text-sm font-semibold text-neutral-900">PPMS</span>
</div>
<div className="flex items-center gap-1">
<NotificationBell
initialUnreadCount={initialUnreadCount}
initialNotifications={initialNotifications}
/>
<button
onClick={() => signOut({ callbackUrl: "/login" })}
className="rounded-lg p-2 text-neutral-500 hover:bg-neutral-100 hover:text-neutral-700 transition-colors"
title="Sign out"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</header>
);
}

View file

@ -271,9 +271,9 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
})()}
{/* Order Details */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Order Details</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-3 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 text-sm">
<div><dt className="text-neutral-500">Cost Centre</dt><dd className="font-medium text-neutral-900">{po.vessel?.name ?? "—"}</dd></div>
<div><dt className="text-neutral-500">Account</dt><dd className="font-medium text-neutral-900">{po.account.name} ({po.account.code})</dd></div>
<div><dt className="text-neutral-500">Requested By</dt><dd className="font-medium text-neutral-900">{po.submitter.name}</dd></div>
@ -298,9 +298,9 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
{/* Vendor */}
{po.vendor ? (
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<div className="rounded-lg border border-neutral-200 bg-white p-4 md:p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-3">Vendor</h3>
<dl className="grid grid-cols-2 gap-x-6 gap-y-2 text-sm">
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
<div><dt className="text-neutral-500">Name</dt><dd className="font-medium text-neutral-900">{po.vendor.name}</dd></div>
<div>
<dt className="text-neutral-500">Vendor ID</dt>
@ -336,7 +336,7 @@ export async function PoDetail({ po, currentUserId, currentRole, readOnly = fals
)}
{/* Line Items */}
<div className="rounded-lg border border-neutral-200 bg-white p-6">
<div className="rounded-lg border border-neutral-200 bg-white p-3 md:p-6">
<h3 className="text-sm font-semibold text-neutral-900 mb-4">Line Items</h3>
<LineItemsEditor
items={lineItemsForEditor}