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:
parent
3259009690
commit
105f169e45
5 changed files with 211 additions and 28 deletions
|
|
@ -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`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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)' }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
46
lib/hooks.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue