feat(problems): say what kind of problem, and flag out-of-stock rows

Every flagged item rendered the same red triangle and the same
"Problem" pill, whichever problem it was. An out-of-stock line and a
quality complaint look identical on the list, and they need different
follow-ups — one is a phone call to the supplier, the other is a
replacement.

problem_type is a PB *select* on order_items (stock|supplier|driver|
delivery|pricing|other, plus the four values added 2026-10-14) but a
free *text* field on stock_purchase_items, so the label map is
deliberately wider than PROBLEM_TYPES: updateStockCollectionStatus
writes `stock` on its own, and stock rows can carry anything.

Out-of-stock rows also get a red left rail and a light red wash. The
pill stays the signal — colour is never the only thing carrying the
meaning, since a red wash alone is invisible to anyone who can't
distinguish it from the hover state.

Colour is deliberately NOT used to disable Collect. Stock can arrive
without anyone clearing the flag, and a driver standing in front of
the shelf is the best possible source of truth.
This commit is contained in:
Dave 2026-10-15 08:24:58 +02:00
parent bb5a585af6
commit 286b86162b
3 changed files with 79 additions and 7 deletions

View file

@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { OrderItem } from '@/lib/types';
import { OrderItem, isOutOfStock, problemBadgeLabel } from '@/lib/types';
import { cn } from '@/lib/utils';
import CollectionItemCard from '@/components/CollectionItemCard';
import {
@ -175,7 +175,14 @@ export default function AreaGroup({
return (
<li
key={item.id}
className="flex items-center justify-between px-4 py-3 hover:bg-[#ebeae5] transition-colors"
className={cn(
'flex items-center justify-between 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]'
)}
>
<div className="flex items-center gap-2.5 min-w-0 flex-1">
{getItemIcon(item)}
@ -190,8 +197,15 @@ export default function AreaGroup({
)}
</div>
{item.problem_flag && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#fbeaef] text-[#8a1b3a] font-medium flex-shrink-0">
Problem
<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>
)}
</div>

View file

@ -1,7 +1,7 @@
'use client';
import { useState, useRef, useEffect, useCallback } from 'react';
import { OrderItem, Supplier } from '@/lib/types';
import { OrderItem, Supplier, isOutOfStock, problemBadgeLabel } from '@/lib/types';
import { cn, parseCoordinates } from '@/lib/utils';
import CollectionItemCard from '@/components/CollectionItemCard';
import {
@ -108,6 +108,10 @@ function CompactItemRow({
<li
className={cn(
'relative flex items-center justify-between 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'
@ -120,8 +124,15 @@ function CompactItemRow({
{item.description || 'Unknown item'}
</span>
{item.problem_flag && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full bg-[#fbeaef] text-[#8a1b3a] font-medium flex-shrink-0">
Problem
<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>
)}
</div>

View file

@ -279,3 +279,50 @@ export const PROBLEM_TYPES = [
{ value: 'wrong_item', label: 'Wrong Item' },
{ value: 'other', label: 'Other' },
] as const;
/**
* Stored `problem_type` -> the word we show the driver.
*
* The union is wider than PROBLEM_TYPES on purpose. `problem_type` is a
* PB **select** on `order_items` whose allowed values are
* stock | supplier | driver | delivery | pricing | other
* plus the four PROBLEM_TYPES entries we added on 2026-10-14. On
* `stock_purchase_items` the same field is a plain **text** field, so
* anything can turn up there too. `collectionStatus`/`updateStockCollectionStatus`
* write `stock` on their own. A generic "Problem" pill told the driver
* nothing about which of these they were looking at — an out-of-stock
* item and a quality complaint need different follow-ups.
*/
export const PROBLEM_TYPE_LABELS: Record<string, string> = {
out_of_stock: 'Out of stock',
partial_stock: 'Partial stock',
quality_issue: 'Quality issue',
wrong_item: 'Wrong item',
stock: 'Short / partial',
supplier: 'Supplier issue',
driver: 'Driver issue',
delivery: 'Delivery issue',
pricing: 'Pricing issue',
other: 'Other',
};
/** Badge text for a flagged item; falls back to the old generic label. */
export const problemBadgeLabel = (item: {
problem_flag?: boolean | null;
problem_type?: string | null;
}): string => {
if (!item.problem_flag) return '';
return PROBLEM_TYPE_LABELS[item.problem_type ?? ''] ?? 'Problem';
};
/**
* True only for a flagged row the driver reported as out of stock.
*
* Deliberately narrow: `stock` (partial collection) and the other
* operator-side types do NOT qualify, because the driver can't clear
* those and they don't block a re-collection the same way.
*/
export const isOutOfStock = (item: {
problem_flag?: boolean | null;
problem_type?: string | null;
}): boolean => Boolean(item.problem_flag) && item.problem_type === 'out_of_stock';