feat: readable compact rows and a full-width PO sheet

- Item names wrap and get the full row width; the quantity sits beside
  the name and the PO / notes / Collect / Problem buttons move to their
  own line underneath, all 44px. Similar bolts truncated to
  "M22 x 150 H…" were impossible to tell apart on a phone.
- The PO and notes buttons open a full-width bottom sheet (portalled to
  <body>, above the BottomNav) instead of a 256px popover pinned to a
  mid-row button, which ran off the left edge of the screen.
- The PO sheet shows status, order and item count, with big Open PO PDF
  and Invoice photo buttons (PoActions size="lg").
- Dropped the popover up/down placement measuring and the
  click-outside listener; the sheet closes on backdrop, X or Escape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-18 10:27:36 +02:00
parent de16082ff7
commit b9a112881d
3 changed files with 239 additions and 228 deletions

View file

@ -0,0 +1,70 @@
'use client';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
interface BottomSheetProps {
title: string;
subtitle?: string;
onClose: () => void;
children: React.ReactNode;
}
// Full-width panel that slides up from the bottom of the screen.
//
// Replaces the small popovers on the compact rows: those were 256px
// wide and pinned to a button in the middle of the row, so on a phone
// they ran off the left edge. Rendered into <body> through a portal so
// no parent's stacking context or overflow can clip it, and it sits
// above the BottomNav.
export default function BottomSheet({ title, subtitle, onClose, children }: BottomSheetProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
if (!mounted) return null;
return createPortal(
<div
data-bottom-sheet
className="fixed inset-0 z-[90] flex items-end bg-black/35 animate-fade-in"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
aria-label={title}
onClick={(e) => e.stopPropagation()}
className="w-full max-h-[80vh] overflow-y-auto bg-white rounded-t-2xl px-4 pt-2 pb-[calc(1.25rem+env(safe-area-inset-bottom))] shadow-[0_-8px_24px_rgba(0,0,0,0.12)]"
>
<div className="w-10 h-1 rounded-full bg-[rgba(38,37,30,0.2)] mx-auto mb-3" aria-hidden="true" />
<div className="flex items-start justify-between gap-3 mb-3">
<div className="min-w-0">
<p className="text-[13px] text-[rgba(38,37,30,0.6)]">{title}</p>
{subtitle && (
<p className="text-[18px] font-semibold text-[#26251e] break-words leading-snug">{subtitle}</p>
)}
</div>
<button
type="button"
onClick={onClose}
aria-label="Close"
className="w-10 h-10 flex-shrink-0 rounded-lg border border-[rgba(38,37,30,0.15)] flex items-center justify-center active:bg-[rgba(38,37,30,0.06)] touch-manipulation"
>
<X size={18} aria-hidden="true" />
</button>
</div>
{children}
</div>
</div>,
document.body,
);
}

View file

@ -13,6 +13,8 @@ interface PoActionsProps {
onAddInvoicePage: (items: OrderItem[], file: File) => Promise<void>;
/** False hides the photo button (e.g. the caller can't upload). */
canPhoto?: boolean;
/** 'lg' = full-width stacked buttons, for the PO bottom sheet. */
size?: 'sm' | 'lg';
className?: string;
}
@ -24,6 +26,7 @@ export default function PoActions({
items,
onAddInvoicePage,
canPhoto: photoAllowed = true,
size = 'sm',
className,
}: PoActionsProps) {
const inputRef = useRef<HTMLInputElement>(null);
@ -53,19 +56,27 @@ export default function PoActions({
};
const poLabel = purchaseOrder?.po_number ? `PO ${purchaseOrder.po_number}` : 'this PO';
const lg = size === 'lg';
const btn = lg
? 'h-12 w-full rounded-xl text-[15px] font-semibold flex items-center justify-center gap-2 transition-colors touch-manipulation'
: 'h-7 px-2 rounded-md text-[11px] font-semibold flex items-center gap-1 transition-colors touch-manipulation';
const icon = lg ? 18 : 11;
return (
<div className={cn('flex items-center gap-1 flex-shrink-0', className)}>
<div className={cn(lg ? 'flex flex-col gap-2' : 'flex items-center gap-1 flex-shrink-0', className)}>
{pdfUrl && (
<a
href={pdfUrl}
target="_blank"
rel="noopener noreferrer"
aria-label={`Open the PDF for ${poLabel}`}
className="h-7 px-2 rounded-md bg-[#efeaf7] text-[#6c5a99] text-[11px] font-semibold flex items-center gap-1 active:bg-[#ded5ef] transition-colors touch-manipulation"
className={cn(
btn,
lg ? 'bg-[#26251e] text-white active:bg-black' : 'bg-[#efeaf7] text-[#6c5a99] active:bg-[#ded5ef]',
)}
>
<FileText size={11} aria-hidden="true" />
<span>PO</span>
<FileText size={icon} aria-hidden="true" />
<span>{lg ? 'Open PO PDF' : 'PO'}</span>
</a>
)}
{canPhoto && (
@ -81,25 +92,29 @@ export default function PoActions({
: `Add page ${pages + 1} of the invoice for ${poLabel}`
}
className={cn(
'h-7 px-2 rounded-md text-[11px] font-semibold flex items-center gap-1 transition-colors touch-manipulation',
pages === 0
? 'bg-[#26251e] text-white active:bg-black'
: 'bg-[#e6f1ec] text-[#125a3e] active:bg-[#cfe6db]',
btn,
pages > 0
? 'bg-[#e6f1ec] text-[#125a3e] active:bg-[#cfe6db]'
: lg
? 'bg-white text-[#26251e] border border-[rgba(38,37,30,0.2)] active:bg-[#f2f1ed]'
: 'bg-[#26251e] text-white active:bg-black',
uploading && 'opacity-60',
)}
>
{uploading ? (
<Loader2 size={11} className="animate-spin" aria-hidden="true" />
<Loader2 size={icon} className="animate-spin" aria-hidden="true" />
) : pages === 0 ? (
<Camera size={11} aria-hidden="true" />
<Camera size={icon} aria-hidden="true" />
) : (
<Check size={11} aria-hidden="true" />
<Check size={icon} aria-hidden="true" />
)}
<span>
{uploading
? 'Uploading…'
: pages === 0
? 'Invoice'
? lg ? 'Invoice photo' : 'Invoice'
: lg
? `Invoice: ${pages} page${pages === 1 ? '' : 's'} · add page ${pages + 1}`
: `${pages} pg · add pg ${pages + 1}`}
</span>
</button>

View file

@ -1,10 +1,11 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { useState, useRef } from 'react';
import { OrderItem, Supplier, isOutOfStock, problemBadgeLabel } from '@/lib/types';
import { cn, parseCoordinates } from '@/lib/utils';
import CollectionItemCard from '@/components/CollectionItemCard';
import PoActions from '@/components/PoActions';
import BottomSheet from '@/components/BottomSheet';
import {
ChevronDown,
ChevronUp,
@ -75,64 +76,64 @@ function getItemIcon(item: OrderItem) {
}
// Compact row markup extracted so the flat-list render and the
// per-PO sub-group render can both share it without duplicating ~180
// lines of popover + tooltip + button code. Tooltip state lives one
// level up so click-outside detection on the wrapping <ul> still works
// when rows are nested inside multiple PO sub-groups.
// per-PO sub-group render can both share it. Which sheet is open lives
// one level up so only one row's sheet can be open at a time.
interface CompactItemRowProps {
item: OrderItem;
/** Every item in this supplier group on the same PO (for the invoice photo). */
poItems: OrderItem[];
activeTooltip: { itemId: string; type: 'po' | 'notes' } | null;
setActiveTooltip: (t: { itemId: string; type: 'po' | 'notes' } | null) => void;
rowPlacement: 'down' | 'up';
measureRowPlacement: (itemId: string, btn: HTMLElement | null) => void;
onShowDetails: (item: OrderItem) => void;
onCollect: (item: OrderItem) => void;
onProblem: (item: OrderItem) => void;
onAddInvoicePage?: (items: OrderItem[], file: File) => Promise<void>;
}
function CompactItemRow({
item,
poItems,
activeTooltip,
setActiveTooltip,
rowPlacement,
measureRowPlacement,
onShowDetails,
onCollect,
onProblem,
onAddInvoicePage,
}: CompactItemRowProps) {
const expectedQty = item.order_qty || item.quantity || 0;
const po = item.expand?.purchase_order;
const poNumber = po?.po_number;
const orderNumber = item.expand?.order?.order_number;
const hasNotes = Boolean(item.notes);
const hasProblemNotes = Boolean(item.problem_notes);
const hasAnyNotes = hasNotes || hasProblemNotes;
const isPoOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'po';
const isNotesOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'notes';
const close = () => setActiveTooltip(null);
return (
<li
className={cn(
'relative flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors',
'relative px-4 py-3 hover:bg-[#ebeae5] transition-colors',
// Out-of-stock rows carry a red left rail + tint so they read as
// blocked without relying on the pill alone. Colour is never the
// only signal — the "Out of stock" pill is always rendered too.
isOutOfStock(item) && 'bg-[#fdf2f4] border-l-[3px] border-l-[#cf2d56] pl-[13px]',
// z-[80] when this row holds the open popover so the popover
// paints above the BottomNav (z-50) *and* above sibling rows.
(isPoOpen || isNotesOpen) ? 'z-[80]' : 'z-10'
)}
>
{/* Left: icon + description */}
<div className="flex items-center gap-2.5 min-w-0 flex-1">
{getItemIcon(item)}
<span className="text-sm text-[#26251e] truncate">
{/* Line 1: icon + description + qty. The description gets the
full row width and wraps instead of truncating — similar bolts
("M22 x 150 H…") were impossible to tell apart on a phone, and
with the buttons beside it the name was squeezed to ~80px. */}
<div className="flex items-start gap-2.5">
<span className="mt-1 flex-shrink-0">{getItemIcon(item)}</span>
<div className="min-w-0 flex-1">
<p className="text-[15px] leading-snug text-[#26251e] break-words">
{item.description || 'Unknown item'}
</span>
</p>
{item.problem_flag && (
<span
className={cn(
'text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0',
'inline-block mt-1 text-[11px] px-1.5 py-0.5 rounded-full font-medium',
isOutOfStock(item)
? 'bg-[#cf2d56] text-white'
: 'bg-[#fbeaef] text-[#8a1b3a]'
@ -142,166 +143,114 @@ function CompactItemRow({
</span>
)}
</div>
<span className="text-[16px] font-semibold text-[#26251e] flex-shrink-0">{expectedQty}×</span>
</div>
{/* Right: qty + FileText + Collect/Problem */}
<div className="flex items-center gap-1.5 flex-shrink-0 ml-2">
<span className="text-sm font-semibold text-[rgba(38,37,30,0.7)]">{expectedQty}×</span>
{/* Line 2: PO + notes + Collect/Problem, right-aligned under the name */}
<div className="flex items-center justify-end gap-1.5 mt-2">
{/* PO FileText button */}
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
measureRowPlacement(item.id, e.currentTarget);
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
}}
aria-label="Show PO details"
aria-label="Show PO"
aria-expanded={isPoOpen}
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors"
className="w-11 h-11 rounded-lg flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors touch-manipulation"
>
<FileText size={11} aria-hidden="true" />
<FileText size={18} aria-hidden="true" />
</button>
{/* PO tooltip popover */}
{isPoOpen && (
<div
className={cn(
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
)}
>
<div className="flex items-center justify-between mb-2">
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Purchase Order</p>
<button
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
aria-label="Close"
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
>
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
</button>
</div>
{po ? (
<>
<dl className="space-y-1.5 text-xs">
<div className="flex justify-between">
<dt className="text-[rgba(38,37,30,0.55)]">PO Number</dt>
<dd className="text-[#26251e] font-medium font-mono">{po.po_number}</dd>
</div>
{po.status && (
<div className="flex justify-between">
<dt className="text-[rgba(38,37,30,0.55)]">Status</dt>
<dd className="text-[#26251e] font-medium capitalize">{po.status}</dd>
</div>
)}
{po.total_amount != null && (
<div className="flex justify-between">
<dt className="text-[rgba(38,37,30,0.55)]">Total</dt>
<dd className="text-[#26251e] font-medium">R{(po.total_amount / 100).toFixed(2)}</dd>
</div>
)}
{orderNumber && (
<div className="flex justify-between">
<dt className="text-[rgba(38,37,30,0.55)]">Order</dt>
<dd className="text-[#26251e] font-medium">{orderNumber}</dd>
</div>
)}
</dl>
<button
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
className="mt-3 w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-[#6c5a99] text-white text-[12px] font-semibold active:bg-[#4a3d6b] transition-colors"
>
<ExternalLink size={13} aria-hidden="true" />
View PO &amp; Document
</button>
</>
) : (
<div className="text-center py-2">
<p className="text-xs text-[rgba(38,37,30,0.55)]">No PO attached</p>
<button
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); onShowDetails(item); }}
className="mt-2 text-[11px] text-[#6c5a99] font-medium hover:underline"
>
View details
</button>
</div>
)}
</div>
)}
</div>
{/* Notes MessageSquare button — only when notes exist */}
{hasAnyNotes && (
<div className="relative">
<button
onClick={(e) => {
e.stopPropagation();
measureRowPlacement(item.id, e.currentTarget);
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
}}
aria-label="Show notes"
aria-expanded={isNotesOpen}
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors"
className="w-11 h-11 rounded-lg flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors touch-manipulation"
>
<MessageSquare size={11} aria-hidden="true" />
<MessageSquare size={18} aria-hidden="true" />
</button>
{/* Notes tooltip popover */}
{isNotesOpen && (
<div
className={cn(
'absolute right-0 z-[60] w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3',
rowPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
)}
>
<div className="flex items-center justify-between mb-2">
<p className="text-[11px] font-semibold text-[#26251e] uppercase tracking-wide">Buyer Notes</p>
<button
onClick={(e) => { e.stopPropagation(); setActiveTooltip(null); }}
aria-label="Close"
className="w-5 h-5 rounded flex items-center justify-center hover:bg-[rgba(38,37,30,0.08)]"
>
<span className="text-[rgba(38,37,30,0.4)] text-sm">×</span>
</button>
</div>
{hasNotes ? (
<>
<p className="text-[11px] text-[#8a5a02] bg-[#fcf3d8] border border-[#f6e3a4] rounded-lg p-2 selectable leading-snug">
{item.notes}
</p>
{hasProblemNotes && (
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 mt-2 selectable leading-snug">
<span className="font-semibold">Problem: </span>{item.problem_notes}
</p>
)}
</>
) : hasProblemNotes ? (
<p className="text-[11px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-lg p-2 selectable leading-snug">
{item.problem_notes}
</p>
) : (
<p className="text-xs text-[rgba(38,37,30,0.55)] text-center py-1">No notes</p>
)}
</div>
)}
</div>
)}
{/* Collect + Problem buttons */}
<div className="flex items-center gap-1">
<button
onClick={() => onCollect(item)}
aria-label={`Collect ${item.description || 'item'}`}
className="w-10 h-10 rounded-lg bg-[#1f8a65] text-white flex items-center justify-center active:bg-[#125a3e] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
className="w-11 h-11 rounded-lg bg-[#1f8a65] text-white flex items-center justify-center active:bg-[#125a3e] transition-colors touch-manipulation"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>
</button>
<button
onClick={() => onProblem(item)}
aria-label={`Report problem for ${item.description || 'item'}`}
className="w-10 h-10 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation min-h-[40px] min-w-[40px]"
className="w-11 h-11 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation"
>
<AlertTriangle size={16} aria-hidden="true" />
<AlertTriangle size={18} aria-hidden="true" />
</button>
</div>
</div>
{isPoOpen && (
<BottomSheet title="Purchase order" subtitle={po?.po_number || 'No PO attached'} onClose={close}>
<p className="text-[14px] text-[#26251e] break-words mb-3">{item.description}</p>
{po && (
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-[14px] mb-4">
{po.status && (
<>
<dt className="text-[rgba(38,37,30,0.6)]">Status</dt>
<dd className="text-[#26251e] capitalize">{po.status}</dd>
</>
)}
{orderNumber && (
<>
<dt className="text-[rgba(38,37,30,0.6)]">Order</dt>
<dd className="text-[#26251e] break-words">{orderNumber}</dd>
</>
)}
<dt className="text-[rgba(38,37,30,0.6)]">On this PO</dt>
<dd className="text-[#26251e]">
{poItems.length} item{poItems.length === 1 ? '' : 's'}
</dd>
</dl>
)}
{po && (
<PoActions
purchaseOrder={po}
items={poItems}
size="lg"
onAddInvoicePage={onAddInvoicePage ?? noUpload}
canPhoto={Boolean(onAddInvoicePage)}
/>
)}
<button
type="button"
onClick={() => { close(); onShowDetails(item); }}
className="mt-2 h-12 w-full rounded-xl text-[15px] font-semibold text-[#6c5a99] flex items-center justify-center gap-2 active:bg-[#efeaf7] touch-manipulation"
>
<ExternalLink size={16} aria-hidden="true" />
Item details
</button>
</BottomSheet>
)}
{isNotesOpen && (
<BottomSheet title="Buyer notes" subtitle={item.description || undefined} onClose={close}>
{hasNotes && (
<p className="text-[15px] text-[#8a5a02] bg-[#fcf3d8] border border-[#f6e3a4] rounded-xl p-3 selectable leading-snug whitespace-pre-wrap break-words">
{item.notes}
</p>
)}
{hasProblemNotes && (
<p className={cn(
'text-[15px] text-[#8a1b3a] bg-[#fbeaef] border border-[#f3c4d2] rounded-xl p-3 selectable leading-snug whitespace-pre-wrap break-words',
hasNotes && 'mt-2',
)}>
<span className="font-semibold">Problem: </span>{item.problem_notes}
</p>
)}
</BottomSheet>
)}
</li>
);
}
@ -347,38 +296,15 @@ export default function SupplierGroup({
// activeTooltip above, this drives whether the section lifts to z-30
// to paint above the next supplier/area group.
const [childPopoverOpen, setChildPopoverOpen] = useState(false);
// Per-row popover placement — kept in a map at the top level rather
// than a hook inside the items.map() (which would violate the
// Rules of Hooks if the items array changed length between renders).
// Each entry is the last-measured direction for that row's popovers;
// default 'down' until the driver clicks the button for the first
// time and we measure the available space below it.
const [rowPlacements, setRowPlacements] = useState<Record<string, 'down' | 'up'>>({});
const listRef = useRef<HTMLUListElement>(null);
// Measure the trigger button on click and decide 'down' vs 'up' for
// its popover. Reserves ~80px for the BottomNav + safe area inset.
const measureRowPlacement = useCallback((itemId: string, btn: HTMLElement | null) => {
if (!btn || typeof window === 'undefined') return;
const rect = btn.getBoundingClientRect();
const available = window.innerHeight - rect.bottom - 80;
setRowPlacements((prev) => ({
...prev,
[itemId]: available < 240 ? 'up' : 'down',
}));
}, []);
// Close tooltip when clicking outside the list
useEffect(() => {
if (!activeTooltip) return;
const handler = (e: MouseEvent) => {
if (listRef.current && !listRef.current.contains(e.target as Node)) {
setActiveTooltip(null);
}
// Row sheets close themselves (backdrop tap, Escape, the X button),
// so there is no click-outside listener any more — it would have
// fired on taps inside the sheet, which is portalled out of this list.
const poItemsFor = (item: OrderItem) => {
const id = item.expand?.purchase_order?.id;
return id ? allItems.filter((i) => i.expand?.purchase_order?.id === id) : [item];
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [activeTooltip]);
return (
<section
className={cn(
@ -553,8 +479,8 @@ export default function SupplierGroup({
item={item}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
rowPlacement={rowPlacements[item.id] ?? 'down'}
measureRowPlacement={measureRowPlacement}
poItems={poItemsFor(item)}
onAddInvoicePage={onAddInvoicePage}
onShowDetails={onShowDetails}
onCollect={onCollect}
onProblem={onProblem}
@ -579,8 +505,8 @@ export default function SupplierGroup({
item={item}
activeTooltip={activeTooltip}
setActiveTooltip={setActiveTooltip}
rowPlacement={rowPlacements[item.id] ?? 'down'}
measureRowPlacement={measureRowPlacement}
poItems={poItemsFor(item)}
onAddInvoicePage={onAddInvoicePage}
onShowDetails={onShowDetails}
onCollect={onCollect}
onProblem={onProblem}