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
+
+
+ {/*
+ 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.
- */}
-
+ ) : 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) => (
+ {/* 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.
+ */}
+
+
+
{/*
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"
/>
{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".
+ */}
@@ -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. */}
+
))}
- {/* 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 (