docs: include rep-scoped list filter plan + summary
The plan + summary landed locally during the implementation but were not committed alongside the code. Both documents describe the rep-scoped Orders / Quotes / Invoicing / Delivery Notes list filter work that already shipped (commits8faa443,ed07617,29eb003,9cf8e39etc). - 2026-08-31-rep-scoped-list-filter.md — the implementation plan - 2026-09-01-rep-scoped-list-filter-SUMMARY.md — post-implementation summary with the commit log + verification notes + the known limitation (Orders/Quotes tab counts are unfiltered for reps) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
742010a9a9
commit
57584798f3
2 changed files with 553 additions and 0 deletions
495
docs/superpowers/plans/2026-08-31-rep-scoped-list-filter.md
Normal file
495
docs/superpowers/plans/2026-08-31-rep-scoped-list-filter.md
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
# Rep-Scoped List Filter Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** When a logged-in PocketBase user's `user_type` includes `"REP"`, the Orders, Quotes, Invoicing, and Delivery Notes list pages show only records tied to that rep (via `customer.rep` or, for orders, the denormalized `orders.rep` field). Everyone else (buyer, quoter, admin, boss, etc.) sees the unfiltered list, unchanged from today.
|
||||
|
||||
**Architecture:** This is a UI-layer filter only — **not** a security boundary. No PocketBase API rules change, no server session plumbing, no changes to `/api/v1/*` auth. Orders and Quotes already return `customer` (and for orders, `customer.rep`) in their expand, so those two pages filter the already-fetched array client-side. Invoicing and Delivery Notes fetch PocketBase directly from the browser (no `/api/v1` route), so those two get a real PB filter clause (`order.customer.rep = "<id>"` / `customer.rep = "<id>"`) added to their existing query.
|
||||
|
||||
**Known limitation (accepted for this pass):** On Orders and Quotes, the tab-count badges and pagination totals (`totalPages`, `counts`) come from the unfiltered dataset — they are not rep-scoped. A rep filtering on Orders/Quotes may see a page with fewer than `perPage` visible rows, or a "Page 1 of N" that doesn't match the filtered count. Invoicing and Delivery Notes don't have this problem — their filter is a real PB query, so their counts are accurate. Fixing Orders/Quotes properly means threading `rep` through `orders-service.ts` / `quotes-service.ts`'s PB filters, which is a larger, separate follow-up (not in scope here).
|
||||
|
||||
**Tech Stack:** Next.js Client Components, PocketBase JS SDK, Vitest.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create:** `src/lib/rep-visibility.ts` — pure helper `isRepUser(userType)`, mirrors the existing `isRep()` pattern in `src/lib/server/tv-dashboard-service.ts:349`.
|
||||
- **Create:** `src/lib/rep-visibility.test.ts` — unit tests for the helper.
|
||||
- **Modify:** `src/app/(app)/orders/page.tsx` — client-side filter on the rendered order list.
|
||||
- **Modify:** `src/app/(app)/quotes/page.tsx` — client-side filter on the rendered quote list.
|
||||
- **Modify:** `src/app/(app)/invoicing/page.tsx` — PB filter clause added to `loadInvoicingRows`.
|
||||
- **Modify:** `src/lib/pb-helpers.ts` — `getDeliveryNotes` gains an optional `repId` param that adds a PB filter clause.
|
||||
- **Modify:** `src/app/(app)/delivery-notes/page.tsx` — passes the current rep's id into `getDeliveryNotes`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `isRepUser` helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/rep-visibility.ts`
|
||||
- Test: `src/lib/rep-visibility.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```typescript
|
||||
// src/lib/rep-visibility.test.ts
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isRepUser } from "./rep-visibility";
|
||||
|
||||
describe("isRepUser", () => {
|
||||
it("returns true when user_type includes REP", () => {
|
||||
expect(isRepUser(["REP"])).toBe(true);
|
||||
});
|
||||
|
||||
it("is case-insensitive", () => {
|
||||
expect(isRepUser(["rep"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when REP is combined with other roles", () => {
|
||||
expect(isRepUser(["BUYER", "REP"])).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for other roles", () => {
|
||||
expect(isRepUser(["BUYER"])).toBe(false);
|
||||
expect(isRepUser(["ADMIN", "QUOTER"])).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for null, undefined, or non-array input", () => {
|
||||
expect(isRepUser(null)).toBe(false);
|
||||
expect(isRepUser(undefined)).toBe(false);
|
||||
expect(isRepUser("REP")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for an empty array", () => {
|
||||
expect(isRepUser([])).toBe(false);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
Run: `npx vitest run src/lib/rep-visibility.test.ts`
|
||||
Expected: FAIL — `Cannot find module './rep-visibility'` (or similar; the module doesn't exist yet).
|
||||
|
||||
- [ ] **Step 3: Write the implementation**
|
||||
|
||||
```typescript
|
||||
// src/lib/rep-visibility.ts
|
||||
/**
|
||||
* `users.user_type` is a PB multi-select. "REP" is the existing
|
||||
* convention for marking a sales rep — see the identical pattern in
|
||||
* `src/lib/server/tv-dashboard-service.ts` (`TV_REP_ROLES` / `isRep`).
|
||||
*/
|
||||
export const REP_USER_TYPE = "REP";
|
||||
|
||||
export function isRepUser(userType: unknown): boolean {
|
||||
if (!Array.isArray(userType)) return false;
|
||||
return userType.some(
|
||||
(value) => typeof value === "string" && value.toUpperCase() === REP_USER_TYPE,
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `npx vitest run src/lib/rep-visibility.test.ts`
|
||||
Expected: PASS — all 6 assertions green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/rep-visibility.ts src/lib/rep-visibility.test.ts
|
||||
git commit -m "feat: add isRepUser helper for rep-scoped list filtering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Orders page — filter to the rep's own orders
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(app)/orders/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Import the auth hook and the helper**
|
||||
|
||||
In `src/app/(app)/orders/page.tsx`, add to the top imports (near the other `@/lib` and `@/components` imports, e.g. after the `getPocketBase` import at line 26):
|
||||
|
||||
```typescript
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { isRepUser } from "@/lib/rep-visibility";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Read the current user and derive the visible order list**
|
||||
|
||||
Inside `OrdersPage`, immediately after the existing `useRouter()` call at line 114 (`const router = useRouter();`), add:
|
||||
|
||||
```typescript
|
||||
const { user } = useAuth();
|
||||
```
|
||||
|
||||
Then, right after the `otherTabCounts` `useMemo` block (ends at line 298, just before the `return (` on line 300), add a new `useMemo`:
|
||||
|
||||
```typescript
|
||||
// Rep-scoped UI filter — reps only see orders tied to them, either
|
||||
// via the denormalized `orders.rep` text field or via
|
||||
// `customer.rep`. Everyone else (buyer/quoter/admin/boss/etc.) sees
|
||||
// the unfiltered list. This filters the already-fetched page of
|
||||
// orders; it does not change what the server returns, so tab
|
||||
// counts / pagination totals stay unscoped — see the plan's "Known
|
||||
// limitation" note.
|
||||
const visibleOrders = useMemo(() => {
|
||||
const userType = (user as { user_type?: string[] } | null)?.user_type;
|
||||
if (!user || !isRepUser(userType)) return orders;
|
||||
return orders.filter(
|
||||
({ order }) =>
|
||||
order.rep === user.id || order.expand?.customer?.rep === user.id,
|
||||
);
|
||||
}, [orders, user]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use `visibleOrders` everywhere the rendered list is read**
|
||||
|
||||
Replace these three usages (leave the `orders` state itself, `fetchOrders`, and the realtime subscription untouched — they still fetch/refetch the full page):
|
||||
|
||||
1. Line 282, `const totalLoaded = orders.length;` → `const totalLoaded = visibleOrders.length;`
|
||||
2. Line 390, `) : orders.length === 0 ? (` → `) : visibleOrders.length === 0 ? (`
|
||||
3. Lines 445–464 (the "N matches in Tab for query" block) — change every `orders.length` reference to `visibleOrders.length`:
|
||||
|
||||
```typescript
|
||||
{searching && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{visibleOrders.length} match{visibleOrders.length === 1 ? "" : "es"} in
|
||||
{" "}
|
||||
{TAB_GROUP_VISUAL[activeTab].label} for
|
||||
```
|
||||
|
||||
4. Line 466, `{orders.map(({ order, items, repName, tabGroups }) => (` → `{visibleOrders.map(({ order, items, repName, tabGroups }) => (`
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
|
||||
Run the dev server (`npm run dev`, or use the existing preview tooling) and:
|
||||
1. Log in as a user whose `user_type` does NOT include `REP` (e.g. an admin/buyer account). Confirm `/orders` shows the same orders as before this change.
|
||||
2. Log in as a user whose `user_type` includes `REP` and who is set as `rep` on at least one customer (or has `orders.rep` set to their id on at least one order). Confirm `/orders` only shows orders for that rep's customers/orders.
|
||||
3. Confirm the "New Order" flow, search, and tab-switching still work (they operate on `orders`/`fetchOrders` unchanged — only the rendered list is filtered).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/\(app\)/orders/page.tsx
|
||||
git commit -m "feat(orders): filter list to the logged-in rep's own orders"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Quotes page — filter to the rep's own quotes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(app)/quotes/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Import the auth hook and the helper**
|
||||
|
||||
Add near the top imports, after the `getPocketBase` import at line 34:
|
||||
|
||||
```typescript
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { isRepUser } from "@/lib/rep-visibility";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Read the current user and derive the visible quote list**
|
||||
|
||||
Inside `QuotesPage`, immediately after `const router = useRouter();` at line 142, add:
|
||||
|
||||
```typescript
|
||||
const { user } = useAuth();
|
||||
```
|
||||
|
||||
After the `userById` `useMemo` block (ends at line 440, just before the `toggleExpand` callback at line 447), add:
|
||||
|
||||
```typescript
|
||||
// Rep-scoped UI filter — same rationale as the Orders page (see
|
||||
// src/lib/rep-visibility.ts). Quotes only carry rep via the
|
||||
// customer relation (no denormalized text field like orders.rep).
|
||||
const visibleQuotes = useMemo(() => {
|
||||
const userType = (user as { user_type?: string[] } | null)?.user_type;
|
||||
if (!user || !isRepUser(userType)) return quotes;
|
||||
return quotes.filter((quote) => quote.expand?.customer?.rep === user.id);
|
||||
}, [quotes, user]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Use `visibleQuotes` in the DataTable and the search-count chip**
|
||||
|
||||
1. Line 870, `data={quotes}` → `data={visibleQuotes}` (the `DataTable` prop).
|
||||
2. Lines 895–919 (the live result-count chip) — change every `quotes.length` reference to `visibleQuotes.length`:
|
||||
|
||||
```typescript
|
||||
{searchInput.trim() && (
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs whitespace-nowrap",
|
||||
loading
|
||||
? "text-muted-foreground/60"
|
||||
: visibleQuotes.length === 0
|
||||
? "text-warn font-medium"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
data-testid="quotes-search-count"
|
||||
>
|
||||
{loading ? (
|
||||
<>Searching…</>
|
||||
) : visibleQuotes.length === 0 ? (
|
||||
<>0 matches for “{searchInput.trim()}”</>
|
||||
) : (
|
||||
<>
|
||||
{visibleQuotes.length} match{visibleQuotes.length === 1 ? "" : "es"} for{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
“{searchInput.trim()}”
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
```
|
||||
|
||||
Leave `itemCounts` / `itemsByQuote` / `expandedIds` untouched — they're keyed by quote id and stay correct regardless of which quotes are visible.
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
|
||||
1. Log in as a non-rep user. Confirm `/quotes` is unchanged.
|
||||
2. Log in as a rep user who is set as `rep` on at least one customer with quotes. Confirm `/quotes` only shows that rep's quotes, across both the tab-list view and the search view (type something into the search box).
|
||||
3. Confirm row expand (chevron → items), Process/Open buttons, and template actions still work.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/\(app\)/quotes/page.tsx
|
||||
git commit -m "feat(quotes): filter list to the logged-in rep's own quotes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Invoicing page — PB filter for the rep's own order items
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/(app)/invoicing/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Import the auth hook and the helper**
|
||||
|
||||
Add near the top imports, after the `getPocketBase` import at line 14:
|
||||
|
||||
```typescript
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { isRepUser } from "@/lib/rep-visibility";
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Thread an optional `repId` through `loadInvoicingRows`**
|
||||
|
||||
Modify the function signature and its first call site (lines 162–182):
|
||||
|
||||
```typescript
|
||||
async function loadInvoicingRows(
|
||||
pb: ReturnType<typeof getPocketBase>,
|
||||
repId: string | null,
|
||||
): Promise<RawInvoicingRow[]> {
|
||||
const repFilter = repId
|
||||
? ` && order.customer.rep = "${escapeFilterValue(repId)}"`
|
||||
: "";
|
||||
const seedRows = await listAllInvoicingRows(pb, INVOICING_STATUS_FILTER + repFilter);
|
||||
const readyOrderIds = Array.from(
|
||||
new Set(
|
||||
seedRows
|
||||
.filter((row) => row.item_status === "ready_invoice")
|
||||
.map((row) => row.expand?.order?.id?.trim() ?? "")
|
||||
.filter(Boolean),
|
||||
),
|
||||
);
|
||||
|
||||
if (readyOrderIds.length === 0) {
|
||||
return seedRows;
|
||||
}
|
||||
|
||||
// No need to re-apply repFilter here — readyOrderIds is already
|
||||
// derived from the rep-scoped seedRows above, so the sibling fetch
|
||||
// (by explicit order id) is implicitly scoped too.
|
||||
const siblingFilter = readyOrderIds
|
||||
.map((orderId) => `order = "${escapeFilterValue(orderId)}"`)
|
||||
.join(" || ");
|
||||
const siblingRows = await listAllInvoicingRows(pb, siblingFilter);
|
||||
return dedupeRawRows([...seedRows, ...siblingRows]);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Compute `repId` in the page and pass it into `load()`**
|
||||
|
||||
Inside `InvoicingPage`, near the top of the component (alongside the other `useState` calls, e.g. right after the `useBooleanPreference` call around line 1371), add:
|
||||
|
||||
```typescript
|
||||
const { user } = useAuth();
|
||||
```
|
||||
|
||||
Then update the `load` callback (lines 1407–1418):
|
||||
|
||||
```typescript
|
||||
const load = useCallback(async () => {
|
||||
setRows(null);
|
||||
try {
|
||||
const pb = getPocketBase();
|
||||
const userType = (user as { user_type?: string[] } | null)?.user_type;
|
||||
const repId = user && isRepUser(userType) ? user.id : null;
|
||||
const items = await loadInvoicingRows(pb, repId);
|
||||
setRows(items);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(message);
|
||||
setRows([]);
|
||||
}
|
||||
}, [user]);
|
||||
```
|
||||
|
||||
The existing `useEffect(() => { ...; void load(); }, [load])` (around line 1483) already re-runs whenever `load`'s identity changes, so it will automatically refetch once `user` resolves from the auth provider (which starts as `null` while PB auth is loading).
|
||||
|
||||
- [ ] **Step 4: Manual verification**
|
||||
|
||||
1. Log in as a non-rep user. Confirm the Invoicing page (`/invoicing`) shows the same rows as before.
|
||||
2. Log in as a rep user tied to at least one customer with rows in `ready_invoice` / `invoiced` / `delivery_note` status. Confirm only that rep's rows appear, in both the "To Invoice" and "Invoiced" tabs.
|
||||
3. Confirm the QuickBooks-send and delivery-note-creation modals still work on a visible row.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/\(app\)/invoicing/page.tsx
|
||||
git commit -m "feat(invoicing): filter rows to the logged-in rep's own orders"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Delivery Notes page — PB filter for the rep's own notes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/lib/pb-helpers.ts`
|
||||
- Modify: `src/app/(app)/delivery-notes/page.tsx`
|
||||
|
||||
- [ ] **Step 1: Add an optional `repId` param to `getDeliveryNotes`**
|
||||
|
||||
In `src/lib/pb-helpers.ts`, replace the existing function (lines 1068–1076):
|
||||
|
||||
```typescript
|
||||
export async function getDeliveryNotes(
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
search = "",
|
||||
repId: string | null = null,
|
||||
) {
|
||||
const pb = getPocketBase();
|
||||
const clauses: string[] = [];
|
||||
if (search) {
|
||||
clauses.push(`(delivery_note_number ~ "${search}" || notes ~ "${search}")`);
|
||||
}
|
||||
if (repId) {
|
||||
clauses.push(`customer.rep = "${repId.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
const filter = clauses.join(" && ");
|
||||
return pb.collection("delivery_notes").getList<DeliveryNote>(page, perPage, {
|
||||
filter,
|
||||
sort: "-created",
|
||||
expand: "order,company,customer",
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
(This is backward-compatible — the new param is optional and defaults to `null`, so the one other behavior — no filter at all when both `search` and `repId` are empty — is unchanged.)
|
||||
|
||||
- [ ] **Step 2: Pass the current rep's id from the Delivery Notes page**
|
||||
|
||||
In `src/app/(app)/delivery-notes/page.tsx`, add to the imports (after the `getDeliveryNotes` import at line 27):
|
||||
|
||||
```typescript
|
||||
import { useAuth } from "@/components/auth-provider";
|
||||
import { isRepUser } from "@/lib/rep-visibility";
|
||||
```
|
||||
|
||||
Inside `DeliveryNotesPage`, right after `const router = useRouter();` at line 112, add:
|
||||
|
||||
```typescript
|
||||
const { user } = useAuth();
|
||||
```
|
||||
|
||||
Update `fetchNotes` (lines 318–352) to compute and pass `repId`, and add `user` to its dependency array:
|
||||
|
||||
```typescript
|
||||
const fetchNotes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const userType = (user as { user_type?: string[] } | null)?.user_type;
|
||||
const repId = user && isRepUser(userType) ? user.id : null;
|
||||
// Fetch a wide page so the client-side tab filter sees the
|
||||
// full set — the previous `perPage=20` made the active
|
||||
// tab silently drop records past rank 20 (the operator
|
||||
// saw "no DN" when the row existed but was buried). 500
|
||||
// is well over the production DN count (≈30) and still
|
||||
// cheap for PB.
|
||||
const result = await getDeliveryNotes(page, 500, search, repId);
|
||||
setNotes(result.items);
|
||||
setTotalPages(result.totalPages);
|
||||
// Batch-fetch the related quotes in one query so the Q
|
||||
// icon can show "create" vs "open" without a per-row
|
||||
// round trip. Failures here are non-fatal — the Q icon
|
||||
// falls back to "create quote" if we don't know whether
|
||||
// a quote exists.
|
||||
if (result.items.length > 0) {
|
||||
try {
|
||||
const ids = result.items.map((n) => n.id);
|
||||
const map = await getQuotesForDeliveryNotes(ids);
|
||||
setQuoteByDnId(map);
|
||||
} catch {
|
||||
setQuoteByDnId(new Map());
|
||||
}
|
||||
} else {
|
||||
setQuoteByDnId(new Map());
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load delivery notes");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, search, user]);
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual verification**
|
||||
|
||||
1. Log in as a non-rep user. Confirm `/delivery-notes` shows the same notes as before.
|
||||
2. Log in as a rep user tied to at least one customer with delivery notes. Confirm only that rep's notes appear, across all four status tabs (Active / Cancelled / Completed / All).
|
||||
3. Confirm search, company grouping, and the Combine-DNs flow still work.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/lib/pb-helpers.ts src/app/\(app\)/delivery-notes/page.tsx
|
||||
git commit -m "feat(delivery-notes): filter list to the logged-in rep's own notes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Full regression pass
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Run the full test suite**
|
||||
|
||||
Run: `npx vitest run`
|
||||
Expected: PASS — no regressions in existing suites (`orders-service.test.ts`, `quotes-service.test.ts`, `boss-dashboard-service.test.ts`, `pocketbase-admin.test.ts`, etc. are all untouched by this plan).
|
||||
|
||||
- [ ] **Step 2: Manual cross-check with a boss/admin account**
|
||||
|
||||
Log in as an admin/boss account (`user_type` does NOT include `REP`) and confirm all four pages — Orders, Quotes, Invoicing, Delivery Notes — show the full, unfiltered list exactly as they did before this plan. This is the most important check: the filter must be strictly opt-in.
|
||||
|
||||
- [ ] **Step 3: Manual cross-check with two different rep accounts**
|
||||
|
||||
If two rep test accounts are available (each tied to different customers), confirm rep A never sees rep B's orders/quotes/invoicing-rows/delivery-notes, and vice versa.
|
||||
|
||||
- [ ] **Step 4: Commit (if any fixes were needed)**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "test: regression pass for rep-scoped list filtering"
|
||||
```
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
# Rep-Scoped List Filter — Implementation Summary
|
||||
|
||||
> Companion to [`2026-08-31-rep-scoped-list-filter.md`](./2026-08-31-rep-scoped-list-filter.md). Records what landed, how it was verified, and what still needs manual browser testing.
|
||||
|
||||
## Result
|
||||
|
||||
All 6 plan tasks complete. Pushed to `origin/main` on 2026-09-01.
|
||||
|
||||
## Commits (newest first)
|
||||
|
||||
| SHA | Message |
|
||||
|-----|---------|
|
||||
| `9cf8e39` | test(invoicing): mock useAuth to keep rep-filter a no-op in tests |
|
||||
| `ed07617` | feat(delivery-notes): filter list to the logged-in rep's own notes |
|
||||
| `29eb003` | feat(invoicing): filter rows to the logged-in rep's own orders |
|
||||
| `8faa443` | feat(quotes): filter list to the logged-in rep's own quotes |
|
||||
| `defa481` | feat(orders): filter list to the logged-in rep's own orders |
|
||||
| `d23c811` | feat: add isRepUser helper for rep-scoped list filtering |
|
||||
|
||||
## Files changed
|
||||
|
||||
- **New:** `src/lib/rep-visibility.ts` — `isRepUser(userType)` + `REP_USER_TYPE` constant, mirrors the `isRep` pattern in `src/lib/server/tv-dashboard-service.ts`
|
||||
- **New:** `src/lib/rep-visibility.test.ts` — 6 unit tests covering: REP present, case-insensitive, mixed roles, other roles, null/undefined/non-array, empty array
|
||||
- **Modified:** `src/app/(app)/orders/page.tsx` — `useAuth` + `visibleOrders` `useMemo` filtering on `order.rep` / `order.expand?.customer?.rep`. Replaces `orders.length` / `orders.map` with `visibleOrders.length` / `visibleOrders.map` at the 4 render sites listed in the plan (kept the `orders` state, `fetchOrders`, and realtime subscription untouched)
|
||||
- **Modified:** `src/app/(app)/quotes/page.tsx` — `useAuth` + `visibleQuotes` `useMemo` filtering on `quote.expand?.customer?.rep`. `DataTable.data` and the live search-count chip read from `visibleQuotes`
|
||||
- **Modified:** `src/app/(app)/invoicing/page.tsx` — `useAuth` + new `repId` param on `loadInvoicingRows`. PB filter clause `&& order.customer.rep = "<id>"` added to the seed fetch; sibling fetch is implicitly scoped because `readyOrderIds` derives from the already-scoped seed rows
|
||||
- **Modified:** `src/lib/pb-helpers.ts` — `getDeliveryNotes` gained an optional `repId: string | null = null` param; when supplied, adds a `customer.rep = "<id>"` clause to the existing filter chain
|
||||
- **Modified:** `src/app/(app)/delivery-notes/page.tsx` — `useAuth` + computes `repId` in `fetchNotes`, passes to `getDeliveryNotes`
|
||||
- **Modified:** `src/app/(app)/invoicing/page.test.tsx` — added `vi.mock("@/components/auth-provider", ...)` returning a stable non-REP user via `vi.hoisted` (otherwise the page's `load` re-fires every render and breaks the `toHaveBeenCalledTimes(2)` assertions)
|
||||
|
||||
## Verification done
|
||||
|
||||
- `npx vitest run src/lib/rep-visibility.test.ts` — 6/6 pass
|
||||
- `npx vitest run` (full suite) — 2441 pass / 15 fail. The same 15 failures occur on `origin/main`'s tree (verified by `git checkout origin/main -- <files>` then re-running). None are new regressions.
|
||||
- `npx tsc --noEmit` — zero errors in any touched file
|
||||
- Plan Task 2-5 "manual verification" steps (login as non-rep, login as rep, login as two reps) are **browser tests** and were **not** run from this terminal. See "Still to do" below.
|
||||
|
||||
## Deviations from the plan
|
||||
|
||||
1. **Orders page `visibleOrders` placement.** Plan said to add the `useMemo` after `otherTabCounts` (line 298), but the plan also changed line 282's `const totalLoaded = orders.length` to `const totalLoaded = visibleOrders.length` — which would forward-reference `visibleOrders`. Moved the `useMemo` to before `totalLoaded` instead. Functionally identical, no behaviour change.
|
||||
2. **Invoicing test mock.** The plan did not anticipate that adding `useAuth` to a page would break a test that doesn't wrap with `AuthProvider`. Added the `vi.mock` in `page.test.tsx` (test fix, separate commit `9cf8e39`). The mock returns a non-REP user so the filter is a no-op for existing test data (which doesn't carry a `customer.rep` field).
|
||||
|
||||
## Still to do (manual browser verification)
|
||||
|
||||
- [ ] Log in as a non-rep user (`user_type` does NOT include `REP`) → confirm `/orders`, `/quotes`, `/invoicing`, `/delivery-notes` show the same rows as before this change
|
||||
- [ ] Log in as a rep user tied to ≥1 customer → confirm `/orders`, `/quotes`, `/invoicing`, `/delivery-notes` only show that rep's rows
|
||||
- [ ] If two rep test accounts are available, confirm rep A never sees rep B's rows (and vice versa) across all four pages
|
||||
- [ ] Tab-switching, search, and "New Order" flow on Orders — these operate on the `orders` state, not the filtered `visibleOrders`, so they should still work; confirm in browser
|
||||
|
||||
## Known limitations (from the plan, accepted for this pass)
|
||||
|
||||
- On **Orders** and **Quotes**, tab-count badges and pagination totals (`totalPages`, `counts`) are derived from the unfiltered dataset — a rep filtering may see a page with fewer than `perPage` visible rows, or a "Page 1 of N" that doesn't match the filtered count. **Invoicing** and **Delivery Notes** don't have this problem because their filter is a real PB query, so their counts are accurate.
|
||||
- Proper fix is threading `rep` through `orders-service.ts` / `quotes-service.ts`'s PB filters — separate, larger follow-up.
|
||||
|
||||
## Pre-existing items not touched
|
||||
|
||||
- `src/app/(auth)/login/page.tsx` — was already modified before this work started; left as-is.
|
||||
- `docs/superpowers/plans/2026-08-31-rep-scoped-list-filter.md` — the plan file itself; remains untracked.
|
||||
Loading…
Reference in a new issue