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:
parent
de16082ff7
commit
b9a112881d
3 changed files with 239 additions and 228 deletions
70
components/BottomSheet.tsx
Normal file
70
components/BottomSheet.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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,26 +92,30 @@ 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'
|
||||
: `${pages} pg · add pg ${pages + 1}`}
|
||||
? lg ? 'Invoice photo' : 'Invoice'
|
||||
: lg
|
||||
? `Invoice: ${pages} page${pages === 1 ? '' : 's'} · add page ${pages + 1}`
|
||||
: `${pages} pg · add pg ${pages + 1}`}
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -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,233 +76,181 @@ 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">
|
||||
{item.description || 'Unknown item'}
|
||||
</span>
|
||||
{item.problem_flag && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] px-1.5 py-0.5 rounded-full font-medium flex-shrink-0',
|
||||
isOutOfStock(item)
|
||||
? 'bg-[#cf2d56] text-white'
|
||||
: 'bg-[#fbeaef] text-[#8a1b3a]'
|
||||
)}
|
||||
>
|
||||
{problemBadgeLabel(item)}
|
||||
</span>
|
||||
)}
|
||||
{/* 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'}
|
||||
</p>
|
||||
{item.problem_flag && (
|
||||
<span
|
||||
className={cn(
|
||||
'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]'
|
||||
)}
|
||||
>
|
||||
{problemBadgeLabel(item)}
|
||||
</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();
|
||||
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
|
||||
}}
|
||||
aria-label="Show PO"
|
||||
aria-expanded={isPoOpen}
|
||||
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={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{hasAnyNotes && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
measureRowPlacement(item.id, e.currentTarget);
|
||||
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
|
||||
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
|
||||
}}
|
||||
aria-label="Show PO details"
|
||||
aria-expanded={isPoOpen}
|
||||
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors"
|
||||
aria-label="Show notes"
|
||||
aria-expanded={isNotesOpen}
|
||||
className="w-11 h-11 rounded-lg flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors touch-manipulation"
|
||||
>
|
||||
<FileText size={11} aria-hidden="true" />
|
||||
<MessageSquare 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 & 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"
|
||||
>
|
||||
<MessageSquare size={11} 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]"
|
||||
>
|
||||
<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>
|
||||
</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]"
|
||||
>
|
||||
<AlertTriangle size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onCollect(item)}
|
||||
aria-label={`Collect ${item.description || 'item'}`}
|
||||
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="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-11 h-11 rounded-lg bg-[#cf2d56] text-white flex items-center justify-center active:bg-[#8a1a3a] transition-colors touch-manipulation"
|
||||
>
|
||||
<AlertTriangle size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</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);
|
||||
// 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];
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
};
|
||||
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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue