feat: popover stays above footer, partial collections must be justified

Two improvements to the collection flow:

1. Popover clipping under BottomNav (last item in list)
   - Root cause: popovers were z-[60] *inside* a parent stacking
     context at z-30, so their effective page z-index was 30 — below
     BottomNav's z-50. Even if the z-index were right, the popover
     extends below the trigger button and could fall below the
     viewport on the last item in a long list.
   - Add lib/hooks.ts with usePopoverPlacement: measures the
     trigger's bottom edge and flips popover direction to "up" when
     there isn't ~240px of room below (reserving 80px for BottomNav
     + safe-area inset).
   - Bump the article/section/li stacking context from z-30 to z-[80]
     when a popover is open so the popover paints above BottomNav
     (z-50). Covers both compact-mode SupplierGroup rows and
     detailed-mode CollectionItemCard via the existing
     onPopoverChange callback.
   - For SupplierGroup: hooks live at the top level (record keyed by
     item.id) to avoid the Rules of Hooks trap of declaring hooks
     inside items.map().

2. Partial collections must be justified
   - In CollectModal, when received qty < expected, require EITHER a
     reason (dropdown: out of stock / wrong item / quality issue /
     damaged / wrong qty packed / other) OR a photo before Confirm
     is enabled. Prevents silent partial submissions with no audit
     trail.
   - The picked reason flows into the auto-generated problem note
     so the operator sees "[Reason] driver note" in PB.

Co-Authored-By: Claude <noreply@anthropic.com>

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-02 22:58:08 +02:00
parent 3259009690
commit 105f169e45
5 changed files with 211 additions and 28 deletions

View file

@ -85,11 +85,12 @@ export default function AreaGroup({
color.light,
`border-[rgba(38,37,30,0.1)]`,
`ring-1 ${color.ring}`,
// Lift to z-30 when a popover is open so it can escape this
// section's stacking context and paint above the next area
// group. Sibling sections otherwise share z-10 and render in
// document order, which would hide a popover behind the next.
childPopoverOpen ? 'z-30' : 'z-10'
// z-[80] (above BottomNav's z-50) when a card popover is open
// so the popover can paint above the bottom chrome. The
// popover's own z-[60] is *inside* this section's stacking
// context, so without this lift the popover would be hidden
// behind the BottomNav.
childPopoverOpen ? 'z-[80]' : 'z-10'
)}
aria-label={`${label} area group`}
>

View file

@ -4,7 +4,7 @@ import { useState, useRef } from 'react';
import { OrderItem } from '@/lib/types';
import { cn, formatCurrency } from '@/lib/utils';
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
import { X, Minus, Plus, Check, Camera, ImagePlus, Trash2 } from 'lucide-react';
import { X, Minus, Plus, Check, Camera, ImagePlus, Trash2, AlertCircle } from 'lucide-react';
interface CollectModalProps {
item: OrderItem | null;
@ -30,6 +30,12 @@ export default function CollectModal({
const [error, setError] = useState('');
const [photo, setPhoto] = useState<File | null>(null);
const [photoPreviewUrl, setPhotoPreviewUrl] = useState<string | null>(null);
// Reason the driver is collecting less than the expected qty. We
// require *something* on a partial — either a picked reason or a
// photo — so a partial collection can't be silently submitted with
// no audit trail. Over-collections still pass through with just
// notes (the driver may genuinely have nothing visual to add).
const [partialReason, setPartialReason] = useState('');
const cameraInputRef = useRef<HTMLInputElement>(null);
const libraryInputRef = useRef<HTMLInputElement>(null);
@ -39,6 +45,8 @@ export default function CollectModal({
const parsedQty = parseFloat(receivedQty) || 0;
const isPartial = parsedQty > 0 && parsedQty < expectedQty;
const isOverQty = parsedQty > expectedQty;
// True once the driver has justified the partial one way or another.
const partialJustified = partialReason.trim() !== '' || photo !== null;
const displayDescription = getItemDisplayDescription(item);
const handlePhotoSelected = (file: File | undefined | null) => {
@ -73,7 +81,23 @@ export default function CollectModal({
return;
}
onConfirm(item, qty, notes, photo);
// Partial collections must be justified — either a picked reason
// or a photo. Without one we refuse to submit so the operator
// always has audit context for a short pickup.
if (isPartial && !partialJustified) {
setError('Please tell us why the quantity is short, or take a photo to continue');
return;
}
// Prefix the driver's free-text notes with the structured reason
// when provided, so the operator sees both in the problem note
// without losing the original wording. The reason goes first
// because that's the most actionable piece.
const finalNotes = isPartial && partialReason
? `[${partialReason}] ${notes}`.trim()
: notes;
onConfirm(item, qty, finalNotes, photo);
};
const adjustQty = (delta: number) => {
@ -191,6 +215,50 @@ export default function CollectModal({
)}
</div>
{/* Partial-collection justification. Only shown when the
driver enters a qty less than expected. We require EITHER
a picked reason OR a photo so the operator always has
some audit context for the short pickup. The reason
flows through into the auto-generated problem note. */}
{isPartial && (
<div
className="p-4 bg-[#fcf3d8] border border-[#f6e3a4] rounded-xl space-y-2"
role="group"
aria-label="Partial collection justification"
>
<div className="flex items-center gap-2">
<AlertCircle size={14} className="text-[#8a5a02] flex-shrink-0" aria-hidden="true" />
<p className="text-xs font-semibold text-[#8a5a02]">
Why are you short? Pick one or take a photo below.
</p>
</div>
<label htmlFor="partial-reason" className="sr-only">Reason for short collection</label>
<select
id="partial-reason"
value={partialReason}
onChange={(e) => { setPartialReason(e.target.value); setError(''); }}
className={cn(
'w-full p-2.5 text-sm bg-[#f2f1ed] border rounded-lg text-[#26251e] focus:outline-none focus:ring-2 focus:ring-[#f54e00]/30 focus:border-[#f54e00] transition-all',
partialReason ? 'border-[rgba(38,37,30,0.2)]' : 'border-[#cf2d56]/40'
)}
aria-required="true"
>
<option value="">Select a reason…</option>
<option value="Out of stock">Supplier out of stock</option>
<option value="Wrong item">Wrong item received</option>
<option value="Quality issue">Quality issue</option>
<option value="Damaged">Damaged goods</option>
<option value="Wrong qty packed">Wrong qty packed</option>
<option value="Other">Other (specify in notes)</option>
</select>
{!partialReason && !photo && (
<p className="text-[10px] text-[#8a5a02]">
Required: pick a reason or attach a photo below to continue.
</p>
)}
</div>
)}
{/* Notes */}
<div>
<label htmlFor="collect-notes" className="block text-sm font-semibold text-[#26251e] mb-2">
@ -294,11 +362,12 @@ export default function CollectModal({
>
<button
type="submit"
disabled={!parsedQty || parsedQty < 0 || (isPartial && !partialJustified)}
className={cn(
'w-full py-4 px-4 rounded-xl text-white font-semibold text-[15px]',
'bg-[#1f8a65] active:bg-[#125a3e] transition-all',
'touch-manipulation press-feedback min-h-[56px]',
(!parsedQty || parsedQty < 0) && 'opacity-50'
(!parsedQty || parsedQty < 0 || (isPartial && !partialJustified)) && 'opacity-50'
)}
style={{ boxShadow: '0 4px 12px rgba(31,138,101,0.3)' }}
>

View file

@ -3,6 +3,7 @@
import { useState, useRef, useEffect } from 'react';
import { OrderItem } from '@/lib/types';
import { cn, formatDate, formatCurrency, parseCoordinates } from '@/lib/utils';
import { usePopoverPlacement } from '@/lib/hooks';
import { getItemDisplayDescription, getItemDisplayQty } from '@/lib/pocketbase';
import {
Check,
@ -124,6 +125,14 @@ export default function CollectionItemCard({
const [showPoTooltip, setShowPoTooltip] = useState(false);
const notesTooltipRef = useRef<HTMLDivElement>(null);
const poTooltipRef = useRef<HTMLDivElement>(null);
// Button refs feed the placement hook so the popover flips above
// the button when there's not enough room below it (e.g. on the
// last item in a long list, where the default top-full direction
// would push the popover under the BottomNav).
const poButtonRef = useRef<HTMLButtonElement>(null);
const notesButtonRef = useRef<HTMLButtonElement>(null);
const { placement: poPlacement, measure: measurePoPlacement } = usePopoverPlacement(poButtonRef);
const { placement: notesPlacement, measure: measureNotesPlacement } = usePopoverPlacement(notesButtonRef);
useClickOutside(notesTooltipRef, () => setShowNotesTooltip(false));
useClickOutside(poTooltipRef, () => setShowPoTooltip(false));
@ -155,7 +164,13 @@ export default function CollectionItemCard({
compact
? 'bg-[#ebeae5] rounded-xl overflow-visible'
: 'bg-[#e6e5e0] rounded-2xl card-press overflow-visible border border-[rgba(38,37,30,0.1)]',
(showNotesTooltip || showPoTooltip) && 'relative z-30',
// z-[80] (above the BottomNav's z-50) when a popover is open,
// so the popover can paint above the bottom chrome. The
// popover's own z-[60] is *inside* this article's stacking
// context, so without this lift the popover's effective page
// z-index is only 80 — wait, this is the article's z. The
// popover stacks at this z in the page context.
(showNotesTooltip || showPoTooltip) && 'relative z-[80]',
item.problem_flag && 'ring-1 ring-[#cf2d56]'
)}
>
@ -264,7 +279,13 @@ export default function CollectionItemCard({
{/* PO tooltip button */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setShowPoTooltip(v => !v); setShowNotesTooltip(false); }}
ref={poButtonRef}
onClick={(e) => {
e.stopPropagation();
measurePoPlacement();
setShowPoTooltip(v => !v);
setShowNotesTooltip(false);
}}
aria-label="Show PO details"
aria-expanded={showPoTooltip}
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#6c5a99] text-white active:bg-[#4a3d6b] transition-colors"
@ -275,7 +296,13 @@ export default function CollectionItemCard({
{showPoTooltip && (
<div
ref={poTooltipRef}
className="absolute right-0 top-full z-[60] mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
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',
// Flip above the button when there's not enough
// room below — keeps the popover out from under
// the BottomNav on the last item in a long list.
poPlacement === 'down' ? 'top-full mt-1' : 'bottom-full mb-1'
)}
>
{po ? (
<>
@ -340,7 +367,13 @@ export default function CollectionItemCard({
{hasAnyNotes && (
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setShowNotesTooltip(v => !v); setShowPoTooltip(false); }}
ref={notesButtonRef}
onClick={(e) => {
e.stopPropagation();
measureNotesPlacement();
setShowNotesTooltip(v => !v);
setShowPoTooltip(false);
}}
aria-label="Show notes"
aria-expanded={showNotesTooltip}
className="w-6 h-6 rounded-md flex items-center justify-center bg-[#8a5a02] text-white active:bg-[#6b4501] transition-colors"
@ -351,7 +384,10 @@ export default function CollectionItemCard({
{showNotesTooltip && (
<div
ref={notesTooltipRef}
className="absolute right-0 top-full z-[60] mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
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',
notesPlacement === '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>

View file

@ -1,6 +1,6 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useState, useRef, useEffect, useCallback } from 'react';
import { OrderItem, Supplier } from '@/lib/types';
import { cn, parseCoordinates } from '@/lib/utils';
import CollectionItemCard from '@/components/CollectionItemCard';
@ -73,8 +73,27 @@ 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;
@ -92,13 +111,13 @@ export default function SupplierGroup({
'relative bg-[#e6e5e0] rounded-2xl overflow-visible transition-all duration-200 border border-[rgba(38,37,30,0.1)]',
'border-l-[6px]',
color.border,
// Bump the section's stacking layer when *any* tooltip inside
// it is open — own compact-list popover OR a detailed-mode
// card popover. Sibling sections all sit at z-10 in document
// order; without this lift, an open popover is occluded by the
// next group. Combining both signals keeps compact + detailed
// modes covered with a single className.
(activeTooltip || childPopoverOpen) ? 'z-30' : 'z-10'
// z-[80] when *any* tooltip inside is open so the popover can
// paint above the BottomNav (z-50). The popover's own z-[60]
// is *inside* this section's stacking context, so without
// this lift the popover's effective page z-index would be
// only 80 (the section's z), still above the BottomNav.
// Combining both signals covers compact + detailed modes.
(activeTooltip || childPopoverOpen) ? 'z-[80]' : 'z-10'
)}
aria-label={`${supplier.suppliername} collection group`}
>
@ -178,17 +197,21 @@ export default function SupplierGroup({
const hasAnyNotes = hasNotes || hasProblemNotes;
const isPoOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'po';
const isNotesOpen = activeTooltip?.itemId === item.id && activeTooltip?.type === 'notes';
// Read per-row placement from the top-level map (no
// hooks inside .map — would break the Rules of Hooks
// if items length changed). Default 'down' until the
// driver clicks the button and we measure.
const rowPlacement = rowPlacements[item.id] ?? 'down';
return (
<li
key={item.id}
className={cn(
'relative flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors',
// Lift the row containing the open tooltip so the
// popover paints above the next list item inside
// this same group (which would otherwise occlude
// it under the default z-10 stacking context).
(isPoOpen || isNotesOpen) ? 'z-30' : 'z-10'
// z-[80] when this row holds the open popover so
// the popover paints above the BottomNav (z-50)
// *and* above sibling rows in this same list.
(isPoOpen || isNotesOpen) ? 'z-[80]' : 'z-10'
)}
>
{/* Left: icon + description */}
@ -213,6 +236,7 @@ export default function SupplierGroup({
<button
onClick={(e) => {
e.stopPropagation();
measureRowPlacement(item.id, e.currentTarget);
setActiveTooltip(isPoOpen ? null : { itemId: item.id, type: 'po' });
}}
aria-label="Show PO details"
@ -224,7 +248,10 @@ export default function SupplierGroup({
{/* PO tooltip popover */}
{isPoOpen && (
<div
className="absolute right-0 top-full z-[60] mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
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>
@ -291,6 +318,7 @@ export default function SupplierGroup({
<button
onClick={(e) => {
e.stopPropagation();
measureRowPlacement(item.id, e.currentTarget);
setActiveTooltip(isNotesOpen ? null : { itemId: item.id, type: 'notes' });
}}
aria-label="Show notes"
@ -302,7 +330,10 @@ export default function SupplierGroup({
{/* Notes tooltip popover */}
{isNotesOpen && (
<div
className="absolute right-0 top-full z-[60] mt-1 w-64 bg-white border border-[rgba(38,37,30,0.15)] rounded-xl shadow-lg p-3"
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>

46
lib/hooks.ts Normal file
View file

@ -0,0 +1,46 @@
'use client';
import { useCallback, useState } from 'react';
export type PopoverPlacement = 'down' | 'up';
interface PopoverPlacementApi {
placement: PopoverPlacement;
/** Measure the trigger button and decide placement. Call this in
* the button's onClick before toggling the popover open so the
* placement is correct for the current scroll position. */
measure: () => void;
}
/**
* Decide whether a popover anchored to a button should render below
* the button (default) or above it. The "down" direction is the
* common case; we flip to "up" when the button is near the bottom of
* the viewport and the popover would otherwise extend below the
* visible area and get hidden behind the BottomNav (z-50).
*
* Reserves ~80px of headroom for the BottomNav (64px) + safe-area
* inset so the popover doesn't push right up against the chrome.
*
* The caller must still ensure the popover's parent stacking context
* sits above the BottomNav in the page context (z >= 50). On its own
* the popover can flip, but if its parent section is z-30 the popover
* still paints behind the BottomNav.
*/
export function usePopoverPlacement(
buttonRef: React.RefObject<HTMLElement | null>,
estimatedHeight: number = 240,
reservedForChrome: number = 80,
): PopoverPlacementApi {
const [placement, setPlacement] = useState<PopoverPlacement>('down');
const measure = useCallback(() => {
const btn = buttonRef.current;
if (!btn || typeof window === 'undefined') return;
const rect = btn.getBoundingClientRect();
const available = window.innerHeight - rect.bottom - reservedForChrome;
setPlacement(available < estimatedHeight ? 'up' : 'down');
}, [buttonRef, estimatedHeight, reservedForChrome]);
return { placement, measure };
}