From 500fc512707612e5a6393df669d2884d2ae89b6b Mon Sep 17 00:00:00 2001 From: Dave Date: Sun, 8 Nov 2026 13:55:13 +0200 Subject: [PATCH] fix(repairs-app): give the board its screen back, and fix search with a status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Board: chips scroll in one row (44px, was 136px over three rows), the board opens on a new "Open" chip (not Done, not Cancelled; derived from the same counts so Open + Done + Cancelled = All), cards say how long the machine has waited, the badge drops when one status is chosen, and the loading line shows a real ellipsis instead of "…". The one-tab bar is hidden until a second tab exists. Repair page: a way back, labels above values, the customer quote and order as plain text (the office pages show prices), the quote listed once, and Cancelled no longer drawn as the step after Done. Book-in: a way back, photos first, placeholders marked "e.g.". listRepairs: brackets each filter clause. The search is an OR chain, so a status filter used to bind to serial_no only and a search returned repairs of any status. Adds status "open". Co-Authored-By: Claude Opus 5.5 --- .../repairs-app/[id]/repair-detail.test.tsx | 39 ++++--- .../repairs-app/[id]/repair-detail.tsx | 85 ++++++++------- .../repairs-app/book-in-form.tsx | 89 ++++++++------- src/app/(repairs-app)/repairs-app/layout.tsx | 6 +- .../repairs-app/repair-board.test.tsx | 43 +++++++- .../repairs-app/repair-board.tsx | 101 ++++++++++++++---- .../repairs-app/tab-bar.test.tsx | 13 ++- src/app/(repairs-app)/repairs-app/tab-bar.tsx | 13 ++- src/lib/server/repairs-service.test.ts | 34 ++++++ src/lib/server/repairs-service.ts | 14 ++- 10 files changed, 311 insertions(+), 126 deletions(-) diff --git a/src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx index bb4d7a2..a38c8ce 100644 --- a/src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx +++ b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx @@ -329,7 +329,9 @@ describe("RepairDetail", () => { * The customer quote, the order and the notes * ----------------------------------------------------------------- */ - it("shows the customer quote and the order, each linking out", async () => { + it("names the customer quote and the order without linking to the office", async () => { + // The office quote and order pages show prices — the phone never + // shows money, so it never sends the operator there either. currentRepair = { ...REPAIR, expand: { @@ -340,12 +342,13 @@ describe("RepairDetail", () => { }; await renderDetail(); - const quoteLinks = screen.getAllByRole("link", { name: "Q-0042" }); - expect(quoteLinks.length).toBeGreaterThan(0); - expect(quoteLinks[0].getAttribute("href")).toBe("/quotes/q1"); - - const orderLinks = screen.getAllByRole("link", { name: "ORD-77" }); - expect(orderLinks[0].getAttribute("href")).toBe("/orders/ord1"); + expect(screen.getByText("Q-0042")).toBeTruthy(); + expect(screen.getByText("ORD-77")).toBeTruthy(); + for (const link of screen.queryAllByRole("link")) { + const href = link.getAttribute("href") ?? ""; + expect(href.startsWith("/quotes")).toBe(false); + expect(href.startsWith("/orders")).toBe(false); + } }); it("says None yet when there is no customer quote and no order", async () => { @@ -356,7 +359,7 @@ describe("RepairDetail", () => { expect(within(card).getAllByText("None yet")).toHaveLength(2); }); - it("lists the customer quote with the other documents", async () => { + it("shows the customer quote once, not again under Documents", async () => { currentRepair = { ...REPAIR, expand: { @@ -366,13 +369,23 @@ describe("RepairDetail", () => { }; await renderDetail(); + expect(screen.getAllByText("Q-0042")).toHaveLength(1); const documents = screen.getByText("Documents").closest("section") as HTMLElement; - expect(within(documents).getByText("Customer quote")).toBeTruthy(); + expect(within(documents).queryByText("Customer quote")).toBeNull(); + }); + + it("has a way back to the board", async () => { + await renderDetail(); expect( - within(documents) - .getByRole("link", { name: "Q-0042" }) - .getAttribute("href"), - ).toBe("/quotes/q1"); + screen.getByRole("link", { name: /all repairs/i }).getAttribute("href"), + ).toBe("/repairs-app"); + }); + + it("keeps Cancelled out of the steps", async () => { + await renderDetail(); + const progress = screen.getByText("Progress").closest("section") as HTMLElement; + expect(within(progress).queryByText(/Cancelled/)).toBeNull(); + expect(within(progress).getByText(/Done/)).toBeTruthy(); }); it("shows the notes and saves them when they change", async () => { diff --git a/src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx index c9733a5..7ba908f 100644 --- a/src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx +++ b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -374,6 +375,17 @@ export function RepairDetail({ repairId }: { repairId: string }) { }} /> + {/* + The installed app has no browser back button, and the board is + the only other screen — so the way back is always on the page. + */} + + ← All repairs + +

{repair.repair_number}

@@ -418,28 +430,28 @@ export function RepairDetail({ repairId }: { repairId: string }) {

{repair.item_description}

-
-
-
Whose machine
+
+
+
Whose machine
{customerName}
-
-
Serial number
+
+
Serial number
{repair.serial_no}
-
-
Customer says
+
+
Customer says
{repair.fault_description}
{repair.supplier_scope ? ( -
-
Supplier to look at
+
+
Supplier to look at
{repair.supplier_scope}
) : null} {supplierName ? ( -
-
Supplier
+
+
Supplier
{supplierName}
) : null} @@ -448,26 +460,26 @@ export function RepairDetail({ repairId }: { repairId: string }) {

Customer quote and order

-
-
-
Customer quote
+
+
+
Customer quote
+ {/* + Plain text, never a link: the office quote and order pages + show prices, and nothing on the phone shows money. + */} {customerQuote ? ( - - {customerQuote.quote_number || customerQuote.id} - + {customerQuote.quote_number || customerQuote.id} ) : ( None yet )}
-
-
Order
+
+
Order
{order ? ( - - {order.order_number || order.id} - + {order.order_number || order.id} ) : ( None yet )} @@ -572,24 +584,6 @@ export function RepairDetail({ repairId }: { repairId: string }) { ); })} - {/* - The customer quote is a quote RECORD, not a file on the - repair — so it links out to the office's quote instead of - opening a file. - */} -
  • -

    Customer quote

    - {customerQuote ? ( - - {customerQuote.quote_number || customerQuote.id} - - ) : ( -

    None yet

    - )} -
  • @@ -617,8 +611,17 @@ export function RepairDetail({ repairId }: { repairId: string }) {

    Progress

    + {repair.status === "cancelled" ? ( +

    + Cancelled — this job stopped here. +

    + ) : null} + {/* + Cancelled is a way out, not the step after Done — so the ladder + holds the six working steps only. + */}
      - {REPAIR_STATUS.map((s: RepairStatus) => ( + {REPAIR_STATUS.filter((s) => s !== "cancelled").map((s: RepairStatus) => (
    1. + {/* The installed app has no browser back button. */} + + ← All repairs +

      Book in a machine

      + {/* + Photos first: the machine is in front of you when you start, and + the photo is the one thing the form will not book in without. + */} +
      + +

      + Take a photo of the machine as it arrived. +

      + + + {photos.length > 0 ? ( +

      + {photos.length} photo{photos.length === 1 ? "" : "s"} added +

      + ) : null} + {photoError ? ( +

      {photoError}

      + ) : null} +
      + + {/* The picker is a named group, not a bare label: the label names the group and the group holds the control. That is what makes @@ -269,7 +316,7 @@ export function BookInForm() { id="machine" value={machine} onChange={(e) => setMachine(e.target.value)} - placeholder="Welding machine" + placeholder="e.g. Welding machine" className="min-h-[56px] text-base" />
    @@ -280,7 +327,7 @@ export function BookInForm() { id="serial_no" value={serialNo} onChange={(e) => setSerialNo(e.target.value)} - placeholder="WM-9" + placeholder="e.g. WM-9" className="min-h-[56px] text-base" />
    @@ -291,7 +338,7 @@ export function BookInForm() { id="fault" value={fault} onChange={(e) => setFault(e.target.value)} - placeholder="Will not strike" + placeholder="e.g. Will not strike" rows={3} />
    @@ -302,7 +349,7 @@ export function BookInForm() { id="supplier_scope" value={supplierScope} onChange={(e) => setSupplierScope(e.target.value)} - placeholder="Strip and quote the strike mechanism" + placeholder="e.g. Strip and quote the strike mechanism" rows={3} />

    @@ -344,40 +391,6 @@ export function BookInForm() {

    -
    - -

    - Take a photo of the machine as it arrived. -

    - - - {photos.length > 0 ? ( -

    - {photos.length} photo{photos.length === 1 ? "" : "s"} added -

    - ) : null} - {photoError ? ( -

    {photoError}

    - ) : null} -
    - {error ? (

    {children} diff --git a/src/app/(repairs-app)/repairs-app/repair-board.test.tsx b/src/app/(repairs-app)/repairs-app/repair-board.test.tsx index 9ac3a1d..7c4cf33 100644 --- a/src/app/(repairs-app)/repairs-app/repair-board.test.tsx +++ b/src/app/(repairs-app)/repairs-app/repair-board.test.tsx @@ -6,7 +6,7 @@ import { waitFor, within, } from "@testing-library/react"; -import { RepairBoard } from "./repair-board"; +import { RepairBoard, arrivedAgo } from "./repair-board"; import { MONEY } from "./money-guard"; // The browser session lives in the PocketBase auth store, not on the @@ -271,7 +271,7 @@ describe("RepairBoard", () => { }); await waitFor( - () => expect(screen.getByText("Showing 2 of 12")).toBeTruthy(), + () => expect(screen.getByText("Showing 2 of 11")).toBeTruthy(), { timeout: 2000 }, ); }); @@ -388,4 +388,43 @@ describe("RepairBoard", () => { // The counts are global — a new search cannot change them. expect(countsCalls().length).toBe(countsBefore); }); + + it("opens on the Open chip and asks the list for open repairs", async () => { + await renderBoard(); + expect(listCalls()[0].url).toContain("status=open"); + const open = chips().getByText(/^Open \d+$/); + expect(open.closest("button")?.getAttribute("aria-pressed")).toBe("true"); + }); + + it("Open, Done and Cancelled add up to All", async () => { + await renderBoard(); + // 12 in all, 1 done, 0 cancelled. + expect(chips().getByText("Open 11")).toBeTruthy(); + expect(chips().getByText("All 12")).toBeTruthy(); + }); + + it("drops the card badges when one status is chosen", async () => { + await renderBoard(); + fireEvent.click(chips().getByText(/With supplier/)); + await waitFor(() => + expect(listCalls().at(-1)?.url).toContain("status=assessing"), + ); + await screen.findByText(/Hydraulic press, 40T/); + // Only the chip says it now — every card would repeat it. + expect(screen.getAllByText(/With supplier/)).toHaveLength(1); + }); + + it("says how long ago each machine arrived", () => { + const now = new Date(2026, 10, 8, 9, 0); + expect(arrivedAgo("2026-11-08 06:00:00.000Z", now)).toBe("Arrived today"); + expect(arrivedAgo("2026-11-07 06:00:00.000Z", now)).toBe("Arrived yesterday"); + expect(arrivedAgo("2026-11-01 06:00:00.000Z", now)).toBe("Arrived 7 days ago"); + expect(arrivedAgo("", now)).toBe(""); + expect(arrivedAgo("not a date", now)).toBe(""); + }); + + it("shows the loading line with a real ellipsis", () => { + render(); + expect(screen.getByText("Loading repairs…")).toBeTruthy(); + }); }); diff --git a/src/app/(repairs-app)/repairs-app/repair-board.tsx b/src/app/(repairs-app)/repairs-app/repair-board.tsx index 9a2040c..8d46993 100644 --- a/src/app/(repairs-app)/repairs-app/repair-board.tsx +++ b/src/app/(repairs-app)/repairs-app/repair-board.tsx @@ -11,6 +11,7 @@ import { type RepairStatus, } from "@/lib/repair-status"; import type { Repair } from "@/lib/types"; +import { SHOW_TAB_BAR, TAB_BAR_SPACE } from "./tab-bar"; type Counts = { total: number; byStatus: Record }; @@ -27,6 +28,16 @@ const EMPTY_COUNTS: Counts = { }, }; +/** + * What the board filters by: one status, every repair (""), or "open" — + * everything not Done and not Cancelled. The board opens on "open" so + * finished jobs do not slowly push new work off the screen. + */ +type BoardFilter = RepairStatus | "open" | ""; + +/** The statuses the "Open" chip leaves out. */ +const CLOSED_STATUSES: RepairStatus[] = ["completed", "cancelled"]; + /** How long to wait after the last keystroke before searching. */ const SEARCH_DEBOUNCE_MS = 300; @@ -57,7 +68,7 @@ export function RepairBoard() { /** The counts have never loaded — the chips say "unknown", not "0". */ const [countsLoaded, setCountsLoaded] = useState(false); const [countsError, setCountsError] = useState(null); - const [status, setStatus] = useState(""); + const [status, setStatus] = useState("open"); const [search, setSearch] = useState(""); /** What the list is actually filtered by — the search, after a pause. */ const [appliedSearch, setAppliedSearch] = useState(""); @@ -160,6 +171,15 @@ export function RepairBoard() { const chips = useMemo( () => [ + { + key: "open" as const, + label: "Open", + // Derived from the same counts as every other chip, so Open + + // Done + Cancelled is always All. + count: + counts.total - + CLOSED_STATUSES.reduce((n, s) => n + (counts.byStatus[s] ?? 0), 0), + }, { key: "" as const, label: "All", count: counts.total }, ...REPAIR_STATUS.map((s) => ({ key: s, @@ -171,6 +191,11 @@ export function RepairBoard() { ); const countsKnown = countsLoaded && !countsError; + /** The count on the selected chip — what "the list" should hold. */ + const activeChipCount = + chips.find((c) => c.key === status)?.count ?? counts.total; + /** One status is selected, so every card's badge would repeat it. */ + const singleStatus = status !== "" && status !== "open"; /** * The number the operator reads as "the cards below". @@ -180,7 +205,7 @@ export function RepairBoard() { * above two cards and the two do not agree. So the list says which * is which: "Showing 2 of 12". * - * The denominator is the count the chips read. When the list itself + * The denominator is the count on the selected chip. When the list itself * is cut off by the per-page cap, the list's own total is the honest * one ("Showing 100 of 137"). */ @@ -193,7 +218,7 @@ export function RepairBoard() { truncated || !searchActive ? listTotal : countsKnown - ? counts.total + ? activeChipCount : listTotal; const showCountLine = searchActive || truncated; @@ -210,8 +235,14 @@ export function RepairBoard() { className="min-h-[48px] w-full rounded-md border bg-background px-3 text-base" /> + {/* + One row that scrolls sideways. Wrapped, the nine chips took three + rows (136px) on a 375px phone and left the cards half the screen. + The negative margin lets the row run to the screen edge so a + half-shown chip says "there is more this way". + */}

    @@ -224,7 +255,7 @@ export function RepairBoard() { aria-pressed={active} onClick={() => setStatus(chip.key)} className={ - "min-h-[40px] rounded-full border px-3 text-sm " + + "min-h-[40px] shrink-0 whitespace-nowrap rounded-full border px-3 text-sm " + (active ? "border-primary bg-primary/10 font-medium" : "") } > @@ -257,7 +288,7 @@ export function RepairBoard() { ) : null} {loading ? ( -

    Loading repairs\u2026

    +

    Loading repairs…

    ) : null} {!loading && !error && repairs.length === 0 ? ( @@ -279,7 +310,7 @@ export function RepairBoard() { className="block rounded-lg border bg-card p-3" >
    -
    +
    {repair.repair_number} {repair.serial_no ? (

    @@ -287,11 +318,14 @@ export function RepairBoard() {

    ) : null}
    - - {REPAIR_STATUS_LABEL[repair.status] || repair.status} - + {/* With one status chosen the badge would only repeat the chip. */} + {singleStatus ? null : ( + + {REPAIR_STATUS_LABEL[repair.status] || repair.status} + + )}

    {repair.item_description}

    {customerName(repair) ? ( @@ -299,23 +333,26 @@ export function RepairBoard() { {customerName(repair)}

    ) : null} + {/* How long it has waited first \u2014 that is what the board is read for. */} +

    + {arrivedAgo(repair.created)} +

    - {photoCount(repair) > 0 - ? photoCount(repair) + - " photo" + - (photoCount(repair) === 1 ? "" : "s") - : "No photos"} - {supplierName(repair) ? " \u00b7 " + supplierName(repair) : ""} + {[photoLabel(repair), supplierName(repair)] + .filter(Boolean) + .join(" \u00b7 ")}

    ))} - {/* Sits just above the shell's fixed tab bar. */} + {/* Sits just above the shell's fixed tab bar (or the screen edge). */}
    + Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()) / 86_400_000; + const days = Math.max(0, Math.round(day(now) - day(then))); + if (days === 0) return "Arrived today"; + if (days === 1) return "Arrived yesterday"; + return `Arrived ${days} days ago`; } diff --git a/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx b/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx index 6b5517c..f329355 100644 --- a/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx +++ b/src/app/(repairs-app)/repairs-app/tab-bar.test.tsx @@ -1,20 +1,27 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; -import { RepairsTabBar, pickActiveTabHref } from "./tab-bar"; +import { RepairsTabBar, SHOW_TAB_BAR, pickActiveTabHref } from "./tab-bar"; vi.mock("next/navigation", () => ({ usePathname: () => "/repairs-app", })); describe("RepairsTabBar", () => { + it("stays hidden while there is only one tab", () => { + // One tab is a strip of screen that goes nowhere new. + expect(SHOW_TAB_BAR).toBe(false); + const { container } = render(); + expect(container.querySelector("nav")).toBeNull(); + }); + it("shows the Repairs tab as a labelled link to the board", () => { - render(); + render(); const link = screen.getByRole("link", { name: /repairs/i }); expect(link).toHaveAttribute("href", "/repairs-app"); }); it("has no dead controls — every control is a link with a real destination", () => { - render(); + render(); expect(screen.queryAllByRole("button")).toHaveLength(0); const links = screen.getAllByRole("link"); expect(links.length).toBeGreaterThan(0); diff --git a/src/app/(repairs-app)/repairs-app/tab-bar.tsx b/src/app/(repairs-app)/repairs-app/tab-bar.tsx index 47a1c6a..87535a1 100644 --- a/src/app/(repairs-app)/repairs-app/tab-bar.tsx +++ b/src/app/(repairs-app)/repairs-app/tab-bar.tsx @@ -14,6 +14,16 @@ import { usePathname } from "next/navigation"; */ const TABS = [{ href: "/repairs-app", label: "Repairs" }]; +/** + * A bar with ONE tab is a 57px strip that goes nowhere new. It stays + * hidden until a second tab exists (Phase 4: "Ask Giellie") — then it + * shows itself, and the shell and the board make room for it. + */ +export const SHOW_TAB_BAR = TABS.length > 1; + +/** Room kept above the bottom edge for the bar: 56px tall + a gap. */ +export const TAB_BAR_SPACE = 72; + /** * The active tab is the one whose href is the LONGEST match for the * current path. @@ -41,9 +51,10 @@ export function pickActiveTabHref( return best; } -export function RepairsTabBar() { +export function RepairsTabBar({ force = false }: { force?: boolean } = {}) { const pathname = usePathname(); const activeHref = pickActiveTabHref(pathname, TABS); + if (!SHOW_TAB_BAR && !force) return null; return (