fix(repairs-app): give the board its screen back, and fix search with a status

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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-11-08 13:55:13 +02:00
parent bbdb235c97
commit 500fc51270
10 changed files with 311 additions and 126 deletions

View file

@ -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 () => {

View file

@ -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.
*/}
<Link
href="/repairs-app"
className="-mb-2 flex min-h-[44px] items-center self-start text-base text-primary"
>
← All repairs
</Link>
<div className="flex items-start justify-between gap-2">
<h1 className="text-2xl font-semibold">{repair.repair_number}</h1>
<Badge variant={REPAIR_STATUS_VARIANT[repair.status] || "secondary"}>
@ -418,28 +430,28 @@ export function RepairDetail({ repairId }: { repairId: string }) {
<section className="rounded-lg border p-3">
<h2 className="font-medium">{repair.item_description}</h2>
<dl className="mt-2 space-y-1 text-sm">
<div className="flex gap-2">
<dt className="text-muted-foreground">Whose machine</dt>
<dl className="mt-2 space-y-2 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Whose machine</dt>
<dd>{customerName}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">Serial number</dt>
<div>
<dt className="text-xs text-muted-foreground">Serial number</dt>
<dd>{repair.serial_no}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">Customer says</dt>
<div>
<dt className="text-xs text-muted-foreground">Customer says</dt>
<dd>{repair.fault_description}</dd>
</div>
{repair.supplier_scope ? (
<div className="flex gap-2">
<dt className="text-muted-foreground">Supplier to look at</dt>
<div>
<dt className="text-xs text-muted-foreground">Supplier to look at</dt>
<dd>{repair.supplier_scope}</dd>
</div>
) : null}
{supplierName ? (
<div className="flex gap-2">
<dt className="text-muted-foreground">Supplier</dt>
<div>
<dt className="text-xs text-muted-foreground">Supplier</dt>
<dd>{supplierName}</dd>
</div>
) : null}
@ -448,26 +460,26 @@ export function RepairDetail({ repairId }: { repairId: string }) {
<section className="rounded-lg border p-3">
<h2 className="font-medium">Customer quote and order</h2>
<dl className="mt-2 space-y-1 text-sm">
<div className="flex gap-2">
<dt className="text-muted-foreground">Customer quote</dt>
<dl className="mt-2 space-y-2 text-sm">
<div>
<dt className="text-xs text-muted-foreground">Customer quote</dt>
<dd>
{/*
Plain text, never a link: the office quote and order pages
show prices, and nothing on the phone shows money.
*/}
{customerQuote ? (
<a href={`/quotes/${customerQuote.id}`} className="underline">
{customerQuote.quote_number || customerQuote.id}
</a>
<span>{customerQuote.quote_number || customerQuote.id}</span>
) : (
<span className="text-muted-foreground">None yet</span>
)}
</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">Order</dt>
<div>
<dt className="text-xs text-muted-foreground">Order</dt>
<dd>
{order ? (
<a href={`/orders/${order.id}`} className="underline">
{order.order_number || order.id}
</a>
<span>{order.order_number || order.id}</span>
) : (
<span className="text-muted-foreground">None yet</span>
)}
@ -572,24 +584,6 @@ export function RepairDetail({ repairId }: { repairId: string }) {
</li>
);
})}
{/*
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.
*/}
<li>
<p className="font-medium">Customer quote</p>
{customerQuote ? (
<a
href={`/quotes/${customerQuote.id}`}
className="underline"
>
{customerQuote.quote_number || customerQuote.id}
</a>
) : (
<p className="text-muted-foreground">None yet</p>
)}
</li>
</ul>
</section>
@ -617,8 +611,17 @@ export function RepairDetail({ repairId }: { repairId: string }) {
<section>
<h2 className="font-medium">Progress</h2>
{repair.status === "cancelled" ? (
<p className="mt-2 font-medium">
Cancelled — this job stopped here.
</p>
) : null}
{/*
Cancelled is a way out, not the step after Done — so the ladder
holds the six working steps only.
*/}
<ol className="mt-2 space-y-1">
{REPAIR_STATUS.map((s: RepairStatus) => (
{REPAIR_STATUS.filter((s) => s !== "cancelled").map((s: RepairStatus) => (
<li
key={s}
className={

View file

@ -1,6 +1,7 @@
"use client";
import { useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@ -225,8 +226,54 @@ export function BookInForm() {
onSubmit={onSubmit}
className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-3"
>
{/* The installed app has no browser back button. */}
<Link
href="/repairs-app"
className="-mb-2 flex min-h-[44px] items-center self-start text-base text-primary"
>
← All repairs
</Link>
<h1 className="text-2xl font-semibold">Book in a machine</h1>
{/*
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.
*/}
<div className="space-y-2">
<Label htmlFor="photos">Photos of the machine</Label>
<p className="text-sm text-muted-foreground">
Take a photo of the machine as it arrived.
</p>
<input
ref={fileRef}
id="photos"
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
capture="environment"
multiple
className="sr-only"
onChange={onPhotosPicked}
/>
<Button
type="button"
variant="outline"
className="min-h-[56px] w-full text-base"
disabled={photos.length >= MAX_PHOTOS}
onClick={() => fileRef.current?.click()}
>
Take a photo
</Button>
{photos.length > 0 ? (
<p className="text-sm">
{photos.length} photo{photos.length === 1 ? "" : "s"} added
</p>
) : null}
{photoError ? (
<p className="text-sm text-destructive">{photoError}</p>
) : null}
</div>
{/*
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"
/>
</div>
@ -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"
/>
</div>
@ -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}
/>
</div>
@ -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}
/>
<p className="text-sm text-muted-foreground">
@ -344,40 +391,6 @@ export function BookInForm() {
</p>
</div>
<div className="space-y-2">
<Label htmlFor="photos">Photos of the machine</Label>
<p className="text-sm text-muted-foreground">
Take a photo of the machine as it arrived.
</p>
<input
ref={fileRef}
id="photos"
type="file"
accept="image/jpeg,image/png,image/webp,image/heic"
capture="environment"
multiple
className="sr-only"
onChange={onPhotosPicked}
/>
<Button
type="button"
variant="outline"
className="min-h-[56px] w-full text-base"
disabled={photos.length >= MAX_PHOTOS}
onClick={() => fileRef.current?.click()}
>
Take a photo
</Button>
{photos.length > 0 ? (
<p className="text-sm">
{photos.length} photo{photos.length === 1 ? "" : "s"} added
</p>
) : null}
{photoError ? (
<p className="text-sm text-destructive">{photoError}</p>
) : null}
</div>
{error ? (
<p
role="alert"

View file

@ -1,7 +1,7 @@
import type { Metadata, Viewport } from "next";
import type { ReactNode } from "react";
import { MobileAuthGate } from "@/app/(mobile)/m/auth-gate";
import { RepairsTabBar } from "./tab-bar";
import { RepairsTabBar, SHOW_TAB_BAR, TAB_BAR_SPACE } from "./tab-bar";
/**
* Repairs phone-app shell.
@ -52,8 +52,8 @@ export default function RepairsAppLayout({
className="min-h-screen bg-background text-foreground antialiased"
style={{
paddingTop: "env(safe-area-inset-top)",
// Room for the fixed tab bar plus the home indicator.
paddingBottom: "calc(env(safe-area-inset-bottom) + 72px)",
// Room for the fixed tab bar (when it shows) plus the home indicator.
paddingBottom: `calc(env(safe-area-inset-bottom) + ${SHOW_TAB_BAR ? TAB_BAR_SPACE : 12}px)`,
}}
>
{children}

View file

@ -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(<RepairBoard />);
expect(screen.getByText("Loading repairs…")).toBeTruthy();
});
});

View file

@ -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<RepairStatus, number> };
@ -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<string | null>(null);
const [status, setStatus] = useState<RepairStatus | "">("");
const [status, setStatus] = useState<BoardFilter>("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".
*/}
<div
className="flex flex-wrap gap-2"
className="-mx-3 flex gap-2 overflow-x-auto px-3 pb-1 [scrollbar-width:none]"
role="group"
aria-label="Filter by status"
>
@ -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 ? (
<p className="p-3 text-sm text-muted-foreground">Loading repairs\u2026</p>
<p className="p-3 text-sm text-muted-foreground">Loading repairs…</p>
) : null}
{!loading && !error && repairs.length === 0 ? (
@ -279,7 +310,7 @@ export function RepairBoard() {
className="block rounded-lg border bg-card p-3"
>
<div className="flex items-start justify-between gap-2">
<div>
<div className="min-w-0">
<span className="font-medium">{repair.repair_number}</span>
{repair.serial_no ? (
<p className="text-xs text-muted-foreground">
@ -287,11 +318,14 @@ export function RepairBoard() {
</p>
) : null}
</div>
<Badge
variant={REPAIR_STATUS_VARIANT[repair.status] || "secondary"}
>
{REPAIR_STATUS_LABEL[repair.status] || repair.status}
</Badge>
{/* With one status chosen the badge would only repeat the chip. */}
{singleStatus ? null : (
<Badge
variant={REPAIR_STATUS_VARIANT[repair.status] || "secondary"}
>
{REPAIR_STATUS_LABEL[repair.status] || repair.status}
</Badge>
)}
</div>
<p className="mt-1 text-base">{repair.item_description}</p>
{customerName(repair) ? (
@ -299,23 +333,26 @@ export function RepairBoard() {
{customerName(repair)}
</p>
) : null}
{/* How long it has waited first \u2014 that is what the board is read for. */}
<p className="mt-1 text-sm font-medium">
{arrivedAgo(repair.created)}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{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 ")}
</p>
</Link>
</li>
))}
</ul>
{/* Sits just above the shell's fixed tab bar. */}
{/* Sits just above the shell's fixed tab bar (or the screen edge). */}
<div
className="fixed inset-x-0 z-30 px-3"
style={{ bottom: "calc(env(safe-area-inset-bottom) + 72px)" }}
style={{
bottom: `calc(env(safe-area-inset-bottom) + ${SHOW_TAB_BAR ? TAB_BAR_SPACE : 12}px)`,
}}
>
<Link
href="/repairs-app/book-in"
@ -349,6 +386,26 @@ function supplierName(repair: Repair): string {
return repair.expand?.supplier?.suppliername ?? "";
}
function photoCount(repair: Repair): number {
return Array.isArray(repair.photos) ? repair.photos.length : 0;
function photoLabel(repair: Repair): string {
const n = Array.isArray(repair.photos) ? repair.photos.length : 0;
if (n === 0) return "No photos";
return n + " photo" + (n === 1 ? "" : "s");
}
/**
* How long ago the machine was booked in — the board's way of showing
* which machines have been waiting longest. Whole days, counted by
* calendar day on the phone's clock.
*/
export function arrivedAgo(created: string | undefined, now = new Date()): string {
if (!created) return "";
// PocketBase writes "2026-11-06 07:12:00.000Z" — a space, not a "T".
const then = new Date(created.replace(" ", "T"));
if (Number.isNaN(then.getTime())) return "";
const day = (d: Date) =>
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`;
}

View file

@ -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(<RepairsTabBar />);
expect(container.querySelector("nav")).toBeNull();
});
it("shows the Repairs tab as a labelled link to the board", () => {
render(<RepairsTabBar />);
render(<RepairsTabBar force />);
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(<RepairsTabBar />);
render(<RepairsTabBar force />);
expect(screen.queryAllByRole("button")).toHaveLength(0);
const links = screen.getAllByRole("link");
expect(links.length).toBeGreaterThan(0);

View file

@ -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 (
<nav
aria-label="Sections"

View file

@ -7,6 +7,7 @@ import {
uploadRepairFile,
getRepairStatusCounts,
linkRepairToOrder,
listRepairs,
} from "@/lib/server/repairs-service";
import { createQuote } from "@/lib/server/quotes-service";
@ -501,3 +502,36 @@ describe("linkRepairToOrder", () => {
);
});
});
describe("listRepairs filter", () => {
beforeEach(() => vi.clearAllMocks());
async function filterFor(options: Parameters<typeof listRepairs>[0]) {
const getList = vi.fn().mockResolvedValue({
items: [],
totalItems: 0,
totalPages: 0,
page: 1,
perPage: 20,
});
vi.mocked(getAdminPocketBase).mockResolvedValue({
collection: () => ({ getList }),
} as unknown as Awaited<ReturnType<typeof getAdminPocketBase>>);
await listRepairs(options);
return String(getList.mock.calls[0][2].filter);
}
it("open leaves out finished and cancelled jobs", async () => {
expect(await filterFor({ status: "open" })).toBe(
'(status != "completed" && status != "cancelled")',
);
});
it("brackets the search so the status applies to every column", async () => {
// Unbracketed, `a || ... || serial_no ~ x && status = y` let a
// search match repairs of ANY status on every column but the last.
const filter = await filterFor({ search: "press", status: "quoted" });
expect(filter.startsWith('(repair_number ~ "press" ||')).toBe(true);
expect(filter.endsWith(') && (status = "quoted")')).toBe(true);
});
});

View file

@ -92,7 +92,11 @@ export interface ListRepairsOptions {
page?: number;
perPage?: number;
search?: string;
status?: RepairStatus | "";
/**
* One status, or `"open"` — every repair that is not finished
* (`completed`) and not `cancelled`. The phone board opens on "open".
*/
status?: RepairStatus | "open" | "";
sort?: string;
}
@ -137,10 +141,14 @@ export async function listRepairs(
const clauses: string[] = [];
const searchClause = buildSearchFilter(options.search ?? "");
if (searchClause) clauses.push(searchClause);
if (options.status) {
if (options.status === "open") {
clauses.push(`status != "completed" && status != "cancelled"`);
} else if (options.status) {
clauses.push(`status = "${options.status}"`);
}
const filter = clauses.join(" && ");
// Each clause is bracketed: the search clause is an OR chain, and
// `a || b && c` would bind the status to the last column only.
const filter = clauses.map((c) => `(${c})`).join(" && ");
const result = await pb.collection(REPAIRS).getList<Repair>(page, perPage, {
filter,