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 (