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:
parent
bbdb235c97
commit
500fc51270
10 changed files with 311 additions and 126 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue