- getOne: resolve singular relation fields via the standard
customer/company/user -> collections/companies/users mappings
and hydrate them under `expand.<relation>`, including one
level of nested expansion.
- create: accept FormData so generateQuotePdf's PDF upload
path is exercised end-to-end; blob entries become a
one-element filename array under their key to mirror PB's
file-field shape.
- create: pin the mock-generated id INTO the row so getList's
fields projection returns a real id to callers (services
reading the returned id, e.g. auto-claim lookup, were
receiving `id: undefined`).
- auto-claim tests: add generic type params to getOne calls
so .status / .claimed_by are typed against the mock.
Operators no longer need to click Claim before Generate. The PDF service stamps claimed_by = <caller> when actorEmail is provided, quote.claimed_by is empty, and the quote is in a non-terminal status. Auto-claim lands atomically with the status/timestamp flip. Skip cases (existing claim, terminal status, missing/invalid actor email) leave claimed_by alone and still render the PDF. Email resolution failures are logged and non-fatal. Surfaces the post-update claimed_by on the route outcome and process page setQuote so the Claimed-by chip refreshes without a refetch.
PocketBase treats `max: 0` on a text field as "use the default 5000-char cap", not unlimited. A Redpath reply with a 5029-char body 400'd on every 2-minute poll for hours on 2026-09-08, wedging every RFQ behind it because the failed email stayed UNSEEN.
Added clampEmailBody (with a truncation marker) and routed email_body writes through it so oversized replies still become quotes instead of blocking the queue. The untruncated body remains in the mailbox, and attachments carry the detail. Raise this cap in lockstep with any PB admin change to the field's max.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operators searching for a customer name (e.g. 'Martin', 'Blythwaite') or
an item term (e.g. 'generator') were seeing their latest quote buried
beneath an older quote that scored higher on the relevance ranker
(exact quote_number > customer name > item description). Pre-Sept-2026
the service sorted by score desc with id as a stable tiebreak, so a
2025 quote_number hit from 2025 outranked a 2026 customer-name match
for the same person.
Score still decides which quotes qualify as matches (the
breakdown.raw > 0 filter upstream of the sort), and the per-hit
score is still surfaced in the response for the UI's 'why this
match' breakdown. What changed: the order within the result set is
now created desc, with quote id as a deterministic tiebreaker for
batch-imported quotes that share a created timestamp.
Added tests:
- orders hits by created desc, regardless of score (regression
guard for the user-reported case)
- breaks ties on identical created timestamps by quote id asc
(stable pagination across page loads)
Renamed 'ranks exact identifier match above item-only match' to
'surfaces both identifier and item matches in the result with
their scores intact' — the assertion still holds because both
fixtures share the default created date, but the test's intent
is now 'both hits surface with their scores preserved', not
'order is determined by score'.
The submit action was previously gated on the 'rejected' status only, so
reps had no way to push an emailed, follow-up, problem, or completed
quote back through the approval queue after a customer came back with
changes. Q202609137 sat in 'Awaiting reply' (emailed) with no UI to
re-route it.
Centralise the resubmit eligibility in quote-status.ts as a single
RESUBMITTABLE_STATUSES set (emailed, approval_pending, approved,
complete, follow_up, problem, mail_failed, email_failed,
partial_approved, rejected) and have quotes-service submitQuote read
from it. The page banner switches from a rejection-only red banner to a
generic ResubmitBanner with two visual variants: destructive for
rejection, info for the other nine states. Cancelled, claimed, sending,
and template quotes stay locked.
Splits the old submitQuote test into 'still rejects cancelled + claimed'
and 'accepts every resubmittable status' so the 10 allowed transitions
are each asserted individually. Also fixes a missing pocketbase-admin
mock that was breaking the submitQuote suite.
setDeliveryNoteOrder only ever wrote the `order` relation. Delivery
notes created while the fields+expand bug (see prior commit) was live
got saved with customer/company/order all blank — this setter is the
only existing entry point that touches the order relation, so it now
also fills customer/company from the linked order when the DN's own
values are blank (never overwrites an operator-set value). Applies on
both the write path and the idempotent-order-unchanged path, so
re-running the same call is enough to finish backfilling an affected
DN once the order is already linked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The single-pin tool (pick one customer, click the map, save) doesn't
scale to how the customer base actually clusters: dozens of contacts
sit at Harmony Kusasalethu, at South Deep Shaft 3, and so on. This adds
a batch counterpart at /visits/pin-tool/batch: type a site name, tick
every customer there, click the map once, and all of them get pinned.
Site is a plain free-text field on customers (Customer.site in
lib/types.ts), not a separate sites collection — simpler to add and
maintain, at the cost of relying on the office typing the name the same
way each time. The batch tool's autocomplete (backed by
listDistinctSites) and its "select everyone already tagged X" shortcut
exist specifically to curb that drift without a second collection to
manage.
- jitterCoordinates (lib/geo.ts) scatters each customer 30-80m from the
clicked point so forty pins on one shaft render as distinct dots on
the office map instead of one marker stacked forty deep. Bounded by
distanceKm in tests, which is the same great-circle maths the
check-in radius match uses.
- assignCustomersToSite / listDistinctSites (customers-service.ts) —
the batch write and its autocomplete source. Each customer is written
independently, mirroring recordVisits: a stale id in a forty-customer
batch shouldn't cost the other thirty-nine.
- use-customer-list.ts extracted from pin-tool-view.tsx (verbatim, no
behaviour change) so the batch view doesn't duplicate the fetch.
- batch-pin-helpers.ts holds the pure decisions (save-readiness order,
filter) the same way pin-tool-helpers.ts does for the single-pin tool
— the Leaflet-dependent view is hard to unit test directly.
Applied the "use client" + dynamic(ssr:false) lesson from the single-pin
tool's earlier 500 up front in batch/page.tsx, with a comment explaining
why it's load-bearing rather than decorative.
99 new/updated tests. Scoped verification only (geo, customers-service,
visits/*) — the wider suite currently shows unrelated failures in
quotes-service/quote-pdf-service from a change on main I did not make
and did not investigate further; none of it touches customers, geo, or
visits.
BLOCKED until one PB admin change: customers.site does not exist yet
(text field, optional). Nothing in this commit writes to it until then.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deployment was failing at `npm run build` with:
quote-pdf-service.ts(487,10): error TS18048: 'itemAt' is possibly 'undefined'
`itemAt` comes from `latestItem.items[0]?.updated` (row type
`{ updated?: string }`), so it's `string | undefined`, while `pdfAt`
was already narrowed to `string` by the earlier `if (!pdfAt)` guard —
comparing the two fails under strict mode.
Semantically, a quote with no active items left (all moved/removed)
has no item timestamp, and that alone shouldn't mark the PDF stale;
only the quote-level timestamp decides in that case.
Verified with a full `npm run build` (exit 0).
Logo — three separate faults, all of which had to be fixed for the
letterhead logo to print at all:
1. receipt.html hardcoded '/wfdash-logo.png' for the sale slip, so it
printed the app's bundled mark (the solid black W block) and never
looked at the letterhead at all.
2. The paths that DID use the letterhead (printTest, cashup) passed
PocketBase's fully-qualified cross-origin URL into imageToRaster(),
which draws to a <canvas> — getImageData() taints and throws on a
cross-origin image, so the .catch(() => null) silently dropped the
logo. pos-letterhead-service now returns a same-origin
/api/v1/files/... URL (per AGENTS.md's file-URL rule).
3. letterhead_settings wasn't whitelisted in that file proxy, so the
same-origin URL would have 403'd.
Verified against the live WESTSCOTT letterhead: its SVG rasterizes to
384x137 at ~15% ink coverage (line art), no tainted canvas.
Also fixes the company name never printing: every builder read
`letterhead.name`, but the POS letterhead record exposes
`display_name` — so the name line silently rendered empty on every
slip, on screen and on paper.
Delivery note now matches the sale slip, per request: gains the logo
and business_info blocks and the same 2x-size title, instead of being
a stripped-down variant. This also reconciles the transport
divergence flagged earlier (Bluetooth baked business info into its
header; the network builder omitted it entirely) — it is now one
reorderable business_info block on both.
resolveBlocks() now slots a newly-shipped block in at its intended
position rather than appending it to the end. Without this, any
letterhead with an already-saved layout would have printed the new
logo/business_info blocks BELOW the signature line.
Found by driving /rep in a real browser session, not by a unit test:
submitting a check-in returned 200 OK with body {"created":0,"pinned":1},
and the visit vanished with no trace while the customer coordinate pin
still landed. Reproduced against the live database (confirmed via
direct PocketBase query) and again after the fix, with the response
now 502 and the entry correctly retained in the outbox.
Root cause was two-layered:
1. visits-service.ts caught every per-visit write error and continued
silently, discarding the message. A per-item failure (one stale
customer id in a batch) is meant to be swallowed so it doesn't cost
the other visits — but the same catch also swallowed a systemic
failure (every item in the batch hitting an identical PB validation
error), which looks completely different: a bare `created: 0` gives
the caller no way to tell "nothing to do" from "everything failed".
recordVisits now returns an `errors: {customerId, message}[]` array
instead of discarding the exception.
2. POST /api/v1/visits returned HTTP 200 whenever the request was
well-formed, regardless of whether anything was actually written.
The offline outbox treats any 2xx as delivered and discards the
entry — so a total write failure was indistinguishable from success
at the one layer that decides whether to keep retrying. The route
now returns 502 when `created === 0`, which the outbox already
retries (see the existing 5xx-keeps-the-entry test), rather than a
4xx, which it treats as permanent and drops.
The concrete trigger was live: rep_visits.position_source doesn't yet
allow "phone" (flagged in the previous commit). That specific gap will
close once it's added to the PB select, but this fix stands on its own
— any future rejected field would have hit the identical silent-loss
path.
12 new tests (service: captured errors on per-item and total failure;
route: 502 vs 200 boundary, repId taken from the session not the body;
outbox: a 502 keeps the entry queued rather than treating it as sent).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rep app: open it on site, it finds who's around you, tick who you
saw, done. Third instance of the per-route-group PWA pattern already
running here (/m boss phone, /counter), so it reuses MobileAuthGate and
gets its own manifest + service worker.
Phone GPS is the primary position source, not the vehicle tracker. The
tracker knows where the bakkie is; the phone knows where the rep is,
and those diverge by a few hundred metres once the rep walks into the
plant. GPS also needs no network, which matters because mine shafts
have poor signal. The tracker is the secondary path
(GET /api/v1/visits/position) for when location is denied or
unavailable. The independently-collected vehicle trail corroborates
check-ins afterwards, so honesty doesn't depend on the phone.
Matching runs on the phone, not the server. The app caches a compact
customer snapshot and runs rankNearbyCustomers locally, so there's one
code path that behaves identically online and off — rather than a
server path plus an offline fallback that only gets exercised in the
places it's hardest to debug.
- (rep)/rep/ layout, page, check-in view, outbox
- visits-service.ts getCustomerSnapshot, recordVisits
- POST /api/v1/visits record a check-in
- GET /api/v1/visits/customers snapshot for the offline cache
- GET /api/v1/visits/position tracker fallback
- public/manifest-rep.json, public/sw-rep.js, pickSwPath() branch
These routes authenticate with the browser session, not AGENT_API_KEY —
the caller is a logged-in rep. The rep id comes from the validated
session rather than the request body, so a client can't log a visit as
someone else.
The outbox uses localStorage rather than IndexedDB deliberately: the
payloads are tiny and localStorage is synchronous, which matters when
the rep hits Save and immediately pockets the phone. Entries are only
removed once the server acknowledges them, so a failed flush retries
instead of dropping a visit — duplicates are preferred over loss, and
surfaced rather than merged. Permanently-rejected entries (4xx other
than 401/408/429) are dropped so one bad row can't wedge the queue.
recordVisits writes each visit independently: a stale customer id
shouldn't cost the other four visits in a queued batch.
BLOCKED until one PB admin change: rep_visits.position_source does not
yet allow "phone" — verified against the live instance, PB returns
400 "Invalid value phone". Add it to the select. Separately,
accuracy_m is silently accepted and discarded (PB ignores unknown
fields), so GPS accuracy is thrown away until that number field is
added; not blocking.
47 new tests. Verified on :4001: /rep serves 200 with the right
metadata, manifest-rep.json and sw-rep.js are served.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
FleetAI has no position-history endpoint, so to answer "where was this
rep's vehicle at 10:20 last Tuesday" we have to record it ourselves.
An n8n Schedule Trigger calls POST /api/v1/tracker/poll every 5 minutes
during working hours; a nightly run derives stops and prunes.
poll: */5 6-18 * * 1-5 (the working-hours window lives in the
cron, not in app settings — disabling
the workflow is the off switch)
nightly: 30 2 * * *
One getTokenBaseLiveData call covers the whole fleet per cycle, and only
genuinely new GPS fixes are written: dedupe keys on (vehicle_no,
fixed_at) against the newest fix already held. A parked tracker keeps
re-reporting the same GPSActualTime, so without this a 5-minute cycle
would store the same row a dozen times an hour.
Raw breadcrumbs are pruned at 35 days; derived rep_stops are kept
indefinitely, since a stop is small and is what reports actually ask
about.
Two bugs found by running this against the real stack, neither of which
the unit tests could have caught:
- FakeTrackerAdapter derived its fix time from Date.now(), so every poll
looked like new movement. It could never reproduce a parked vehicle —
the exact case dedupe exists for — and made the poller look correct
while storing a duplicate every cycle. The fake now quantises its fix
time to a bucket, so repeated polls inside one window return an
identical fix.
- PocketBase's filter parser silently matches NOTHING against an
ISO-8601 `T` separator. `fixed_at >= "2026-09-06T00:00:00.000Z"`
returned 0 rows where the space-separated form returned 2, with no
error either way — the rollup would have derived no stops and the
prune deleted no rows, both reporting success, forever. Fixed with a
`pbDate()` helper matching the existing convention in
boss-dashboard-service.ts and order-delivery-note-link-service.ts.
Verified end to end on :4001 against the live PocketBase: three
consecutive polls returned stored=0/duplicates=1, and the nightly rollup
went from vehicles=0 to vehicles=1 once the date format was fixed. Test
rows were removed afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Radius-based rep check-in has nothing to match against until customers
carry real positions: 0 of 616 had any — every `coordinates` geoPoint
was PocketBase's `{lon: 0, lat: 0}` default, and most company addresses
are P.O. Boxes, so geocoding can't seed them either. This is the tool
that seeds the ~20 main West Rand shafts by hand.
- /visits landing page; a launcher until the visit-history
view has data to show
- /visits/pin-tool pick a customer, click the map, save
- setCustomerCoordinates() in customers-service.ts, with lat/lng range
validation so a string off a form input can't write a poisoned row
- PATCH /api/v1/customers/[id]/coordinates for agent/MCP callers
- Visits entry in the sidebar's Management group, plus MOBILE_TITLE_MAP
The browser writes through a Server Action, not the /api/v1/ route:
that route sits behind `assertAgentAuth` and a browser has no
AGENT_API_KEY. Both paths share `setCustomerCoordinates`. Mirrors the
existing saveAiSettingsAction split.
Pure view logic lives in pin-tool-helpers.ts so it can be tested away
from the Leaflet runtime.
Also refreshes the PB schema export — `rep_visits`, `rep_stops`,
`rep_vehicle_positions` and `users.tracker_vehicle_id` now exist on the
live instance.
32 tests. The suite's 9 pre-existing failing files are unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Groundwork for the rep visit check-in feature: a rep taps "I'm on site",
the vehicle's tracker position surfaces which customers are there, and
the visit is logged against them.
Pure/unit-testable layers only — no PocketBase writes and no UI yet,
both of which are blocked on schema that has to be added in the PB
admin first. Design doc: docs/superpowers/specs/2026-09-05-rep-visit-check-in-design.md
- lib/visit-matching.ts rankNearbyCustomers: radius match around a
point, rep's own customers first
- lib/visit-stops.ts detectStops: breadcrumb trail -> stops.
Anchor-based clustering rather than a running
centroid, which would let a slow crawl chain
into one bogus stationary stop
- lib/tracker-parsing.ts Netstar FleetAI response decoding
- lib/server/tracker-service.ts
FleetAiAdapter + FakeTrackerAdapter behind a
TrackerAdapter seam. The fake is auto-selected
when no credentials are configured, so the rest
of the feature builds and tests without vendor
access.
- lib/geo.ts isRealGeoPoint, lifting the Null-Island guard
that was inlined identically in customers/[id]
and companies/[id]
Three FleetAI conventions drive real code here and are each pinned by a
test: errors arrive as HTTP 200 with a failure envelope; "--" means
"unavailable" and is not null (and for IGN, not false); timestamps are
platform-local UTC+2 with no offset marker, so parsing them naively puts
every fresh fix two hours in the past.
FleetAI has no position-history endpoint, so the design polls
getTokenBaseLiveData (whole fleet in one call) and keeps its own trail.
scripts/fleetai-probe.mjs is a throwaway read-only probe to confirm the
three account-shaped assumptions before more is built on top.
102 tests pass. The 9 pre-existing failing test files elsewhere in the
suite were verified identical on a stashed tree and are unrelated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- checkoutSale's stock-availability loop called pb.getOne() with no
try/catch, so a stale/deleted stock_item_id in a persisted cart
threw all the way up to a bare 500 with no useful message. Now
caught and returned as a clear pb_error the till can display.
- Removed the insufficient-stock block entirely: the till is used
ahead of a physical stock take, so a sale must never be blocked
for lack of stock — current_qty is now allowed to go negative and
gets corrected at the next stock take. Drops the now-dead
insufficient_stock/shortages branches from the route, service
types, and client error handling.
Enhanced error messages to include specific field validation details from PocketBase, allowing callers to identify issues more easily. Additionally, implemented a safeguard to truncate the email body to a maximum of 5000 characters to prevent validation errors from surfacing as generic messages.
Added support for discounts in the POS checkout process, allowing operators to apply either a fixed amount or a percentage off the gross total. Updated the state management to include discount details and modified the receipt and cart rendering to display pre- and post-discount values. Enhanced the checkout API to handle discount inputs and ensure proper calculations. Updated relevant tests to cover new discount logic.
Enhanced the PDF generation for quotes by expanding the customer relation to include the associated company information. This allows the company name to be displayed prominently in the "TO:" block of the PDF, improving clarity for users. Updated test data to reflect the new structure and ensure proper rendering of the company name when available.
The pricing modal saves quote_description correctly, but the PDF
template rendered item.description unconditionally, so the
operator's edit never showed up on the customer-facing quote.
Auth gates redirected to a bare /login with no memory of where the
user was headed, and the login page always sent them to /dashboard
afterward — so opening the /m PWA while signed out landed you on the
main dashboard instead of back on /m. Both gates now pass ?next=,
and login honors it (falling back to /dashboard if missing/unsafe).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a chevron button at the left edge of every delivery-note row on
/deliveries. Clicking reveals an inline items panel (description +
qty) so the operator can match against the printed DN paper in their
hand before loading.
Items come from a client-side filter of the order group's items array
(items.filter(i => i.delivery_note === dn.id)). No new server endpoint.
DNs whose items aren't in the order group's slice show a 'See full DN →'
link to /delivery-notes/[id].
Expand state is held at the OrderGroupView level as a Set<string> of
expanded DN ids — independent per DN, resets on refresh. (State lifted
to OrderGroupView rather than DeliveriesPage because order.items is
already in scope there; avoids prop-drilling through CustomerGroupView.)
Service change: deliveries-service.listDeliveryItems now fetches the
delivery_note (relation id) + unit fields on each item, so the page
can filter and render units without an extra round-trip.
Co-Authored-By: Claude Code <noreply@anthropic.com>
Single getList pulls every quote this rep created in the last 62 days,
then buckets client-side into the 6 day/week/month windows plus the
8-week trend. Also fans out the 4 remaining queries (outstandingQuotes,
outstandingOrderItems probe, monthRevenue, the consolidated quotes
fetch) in parallel via Promise.all.
Query count: 18 → 5. Page load bounded by the slowest query instead of
the sum. The widest quote window is "last month" (~62 days), so that
single bound covers every other day/week/month window for bucketing.
Cap at 2000 rows to defend against runaway responses for very active
reps.
Pure function: getOrderAge(created, now?) returns {daysOld, bucket, label}
in calendar-day delta for Africa/Johannesburg. bucketToBadgeClass maps
to tailwind classes (fresh/recent/slipping/stale). No React, no DB.
Vitest unit tests cover all 5 bucket thresholds, future-date clamp, and
the JNB timezone edge case (same calendar day in JNB = daysOld 0).
Co-Authored-By: Claude Code <noreply@anthropic.com>
- Drop Cost and Markup gauge — Selling is the headline the rep cares about
- monthRevenue is now {sellingThisMonth, sellingLastMonth}
- One combined PB query covers both months; classify each row by date in code so the delta costs zero extra round-trips
- RepKpiCard gains a format="currency" prop so Selling renders ZAR-formatted with a delta arrow (mirrors the quote-count cards)
- Row 1 grid adjusts from 6-col to 4-col to match the new card count
- Row 3 now shows separate Selling + Cost KPI cards (ZAR-formatted) plus the Markup gauge
- Outstanding-quotes, outstanding-order-items probe, and month-revenue queries now run in parallel via Promise.all (was sequential, costing ~300ms)
- Outstanding-order-items probe capped at 200 rows so a single rep's data stays in a single PB round-trip instead of paginating
The reps list seeded a row for every `users` record (admin, test
accounts, drivers, duplicate entries), not just the 6 real sales reps.
Also, window filters built with toISOString() used a "T"-separated
date PB's filter engine doesn't match against `created`, so every
window silently returned zero rows — the whole dashboard read 0
regardless of real activity. Same fix pattern already used in
order-delivery-note-link-service.ts / order-order-duplicate-link-service.ts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Code-review feedback from Task 2:
- Code-review Issue #1 (Important): the strict-equality guard fired on
'before.cost_price = undefined' vs 'update.costPrice = 0' — exactly
the population this feature targets (fresh catalogue rows the
operator is pricing for the first time). No real cost movement was
happening, but a spurious ledger row was getting written. Gate the
ledger write on Number.isFinite(before.cost_price) so first-time
pricing doesn't pollute audit history with a 0 -> 0 'movement'.
- Code-review Issue #4 (Minor, audit-readability): add the actual
before -> after delta to the ledger notes so operators scrolling
the ledger history see 'Gary edited cost 2.50 -> 3.20 from /stock'
rather than the prior opaque 'Gary edited cost from /stock'.
- Added two regression tests: one for the first-time-pricing case,
one pinning the notes delta format.
Code-review feedback from Task 1:
- Drop the unused FORBIDDEN_KEYS constant + its export. The whitelist
PATCHABLE_KEYS already covers what was meant; nothing else reads this.
- Replace UpdateStockItemsPricingResultRow's reason: string with a tight
UpdateStockItemsPricingFailureReason union so the route can map to HTTP
status without parsing the message string. Matches UpdateStockItemResult
in stock-items-service.ts:113.
- Replace the array-tail-fragile 'last push matches current id' guard
with an explicit validationFailed boolean per row, decoupling from
push order in the inner loop.
Each stock item now renders a Font Awesome icon in the catalog grid
in place of the generic '?' initials. The icon comes from the item's
category, set on the categories collection's new 'icon' field (e.g.
'fa-solid fa-bolt' for Fasteners). All 10 active categories seeded
with sensible defaults; can be edited per-row in PB admin.
Match logic lives in pos-service.listActiveStockCatalog: it pulls the
categories matcher table, then assigns each item by:
1. category.match_brands (against item text or supplier's supply_tags)
2. category.match_supplier_tags
3. category.match_keywords (longest first so 'hydraulic hose' beats 'hose')
4. the 'Unknown / Other' catch-all is excluded from matching — anything
with no match simply gets the initials fallback
The matcher table is in PB (the existing match_keywords/match_supplier_tags
/match_brands fields on categories), so admins can tune coverage without
a deploy. Two keyword additions to Fasteners (allen cap, set screw) and
Cutting Tools (tap, drill, chuck) lifted the live match rate from 67% to
71% (298/418 items across 8 categories).
FA 6.5.2 Free, solid only, bundled under public/pos/fa/ (256KB total:
100KB CSS + 156KB solid woff2). Both the CSS and the woff2 are in the
service worker APP_SHELL so icons render offline. No CDN dependency.
Co-Authored-By: Claude <noreply@anthropic.com>
Cross-cutting review tab at the end of the strip — lists every order
with any of the three duplicate-detection flags set:
- order_number_duplicate
- potential_duplicate_order
- potential_delivery_note
Implementation:
- src/lib/item-status-ui.ts: add 'duplicates' to TabGroup + visual +
TAB_GROUP_ORDER (placed last so it doesn't compete with
active-workflow tabs). Empty array in TAB_GROUP_ITEM_STATUSES
(it's not an item-status group — the duplicates tab is
cross-cutting on order-header fields).
- src/lib/server/orders-service.ts: add duplicateOrderFilter() +
orderHasAnyDuplicateFlag() helpers. listOrdersWithItems routes
tabGroup='duplicates' to a PB filter on the orders collection
(separate from the item-level tabGroupFilter the other tabs use).
getOrderItemStatusTabCounts counts duplicates independently so
the badge stays accurate even when the active-workflow counts
are filtered out.
- src/app/(app)/orders/page.tsx: add the tab to the strip + an
empty state with the purple #a855f7 visual.
The till was painting all 373 active stock items at once, which is slow
and buries the stock staff actually reach for. The catalog now comes back
newest-first (PosCatalogItem carries created) and renders 50 at a time,
with a Show-more button that also auto-loads as it scrolls into view.
Header reads 'Recently added' when browsing and 'Results' once a search
or category is applied; changing either resets paging to the first chunk.
Scroll position is preserved across re-renders so adding to the cart no
longer throws the operator back to the top of the list.
Co-Authored-By: Claude <noreply@anthropic.com>
Records all four tender types. Only cash sets cash_given/change_given;
card, EFT and account settle outside the till, so cashup can reconcile
the physical drawer. Stock decrements for every tender including
account, since the goods leave on a delivery note.
client_ref makes checkout idempotent: an offline-queued sale that was
already committed server-side replays to the original record instead of
decrementing stock twice.
Co-Authored-By: Claude <noreply@anthropic.com>