d1b1047368
Catalog Database-View UX & Legacy Consolidation (OFFER-07/08/09/10/13) - 11-01: tags table + polymorphic junction + additive legacy migration - 11-02: getAllServices tag join + inline-edit/tag/quick-add server actions - 11-03: EditableCell + TagMultiSelect components - 11-04: database-view ServiceTable + client-side search Verified by gsd-plan-checker (VERIFICATION PASSED). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
542 lines
27 KiB
Markdown
542 lines
27 KiB
Markdown
---
|
|
phase: 11-catalog-database-view-ux-legacy-consolidation
|
|
plan: 04
|
|
type: execute
|
|
wave: 4
|
|
depends_on: ["11-03"]
|
|
files_modified:
|
|
- src/components/admin/catalog/ServiceTable.tsx
|
|
- src/app/admin/catalog/page.tsx
|
|
- src/components/admin/catalog/ServiceForm.tsx
|
|
autonomous: true
|
|
requirements: [OFFER-07, OFFER-08, OFFER-09, OFFER-10]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "L'utente clicca su una cella della tabella services, la modifica inline e il salvataggio avviene con invio (no modali, no reload)"
|
|
- "L'utente assegna tag multi-select a un servizio, creando nuovi tag al volo senza uscire dalla tabella"
|
|
- "L'utente aggiunge un nuovo servizio scrivendo in una riga vuota in fondo alla tabella e premendo invio"
|
|
- "L'utente filtra/cerca i servizi istantaneamente (client-side, nessun reload pagina)"
|
|
- "Servizi disattivati restano visibili, attenuati, ordinati in fondo sotto una riga divisoria"
|
|
- "Nessuna colonna azioni — tutte le modifiche avvengono via celle inline (toggle per active)"
|
|
artifacts:
|
|
- path: "src/components/admin/catalog/ServiceTable.tsx"
|
|
provides: "Database-view table: inline-editable rows, TagMultiSelect column, quick-add row, active/inactive split"
|
|
contains: "EditableCell"
|
|
- path: "src/app/admin/catalog/page.tsx"
|
|
provides: "Catalog page with client-side search/filter bar above the table"
|
|
contains: "ServiceTable"
|
|
- path: "src/components/admin/catalog/ServiceForm.tsx"
|
|
provides: "Removed or reduced to no-op — quick-add row in ServiceTable replaces it"
|
|
key_links:
|
|
- from: "src/app/admin/catalog/page.tsx"
|
|
to: "src/components/admin/catalog/ServiceTable.tsx"
|
|
via: "passes ServiceWithTags[] + search query to filter"
|
|
pattern: "ServiceWithTags"
|
|
- from: "src/components/admin/catalog/ServiceTable.tsx"
|
|
to: "src/components/ui/editable-cell.tsx, src/components/ui/tag-multi-select.tsx"
|
|
via: "renders EditableCell per column, TagMultiSelect for tags column"
|
|
pattern: "EditableCell|TagMultiSelect"
|
|
- from: "src/components/admin/catalog/ServiceTable.tsx"
|
|
to: "src/app/admin/catalog/actions.ts"
|
|
via: "updateServiceField, quickAddService calls on save"
|
|
pattern: "updateServiceField|quickAddService"
|
|
---
|
|
|
|
<objective>
|
|
Rewrite `/admin/catalog` as a Notion/Airtable-style database view: every cell is click-to-edit via `EditableCell`, tags are managed via `TagMultiSelect`, a quick-add row at the bottom creates new services with just a name + Enter, a search bar above the table filters client-side instantly, and inactive services sink below a divider, attenuated. No "Actions" column — the active/inactive state is itself an inline toggle cell (D-14).
|
|
|
|
Purpose: This is the user-facing payload of Phase 11 — OFFER-07, OFFER-08, OFFER-09, OFFER-10 all manifest here, consuming Plan 02's query/actions and Plan 03's `EditableCell`/`TagMultiSelect`.
|
|
|
|
Output:
|
|
- `src/components/admin/catalog/ServiceTable.tsx` — full rewrite (ServiceRow + ServiceTable + quick-add row + active/inactive split)
|
|
- `src/app/admin/catalog/page.tsx` — adds client-side search bar, passes `ServiceWithTags[]`
|
|
- `src/components/admin/catalog/ServiceForm.tsx` — removed (quick-add row replaces its functionality); `page.tsx` no longer imports it
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md
|
|
@.planning/DESIGN-SYSTEM.md
|
|
</context>
|
|
|
|
<interfaces>
|
|
<!-- From src/lib/admin-queries.ts (Plan 02) -->
|
|
```typescript
|
|
export type ServiceWithTags = Service & { tags: string[] };
|
|
export async function getAllServices(): Promise<ServiceWithTags[]>;
|
|
```
|
|
|
|
<!-- From src/app/admin/catalog/actions.ts (Plan 02) -->
|
|
```typescript
|
|
export async function updateServiceField(
|
|
serviceId: string,
|
|
fieldName: "name" | "description" | "category" | "unit_price" | "active",
|
|
value: string | boolean
|
|
): Promise<void>;
|
|
export async function quickAddService(name: string): Promise<void>;
|
|
// addTagToService / removeTagFromService consumed internally by TagMultiSelect — not called directly here
|
|
```
|
|
|
|
<!-- From src/components/ui/editable-cell.tsx (Plan 03) -->
|
|
```typescript
|
|
export interface EditableCellProps {
|
|
value: string | number | boolean;
|
|
type?: "text" | "number" | "textarea" | "toggle";
|
|
onSave: (value: string) => void;
|
|
required?: boolean;
|
|
placeholder?: string;
|
|
disabled?: boolean;
|
|
formatDisplay?: (value: string) => string;
|
|
}
|
|
export function EditableCell(props: EditableCellProps): JSX.Element;
|
|
```
|
|
|
|
<!-- From src/components/ui/tag-multi-select.tsx (Plan 03) -->
|
|
```typescript
|
|
export interface TagMultiSelectProps {
|
|
tags: string[];
|
|
serviceId: string;
|
|
onTagsChanged?: () => void;
|
|
}
|
|
export function TagMultiSelect(props: TagMultiSelectProps): JSX.Element;
|
|
```
|
|
|
|
<!-- src/components/ui/input.tsx — Input (forwardRef<HTMLInputElement>) for the search bar -->
|
|
<!-- lucide-react v1.14.0 — Search icon available for the search bar per DESIGN-SYSTEM.md -->
|
|
|
|
<!-- Current src/app/admin/catalog/page.tsx (full file, 29 lines) — to be modified -->
|
|
```typescript
|
|
import { getAllServices } from "@/lib/admin-queries";
|
|
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
|
import { ServiceForm } from "@/components/admin/catalog/ServiceForm";
|
|
|
|
export const revalidate = 0;
|
|
|
|
export default async function CatalogPage() {
|
|
const services = await getAllServices();
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
|
</div>
|
|
<div className="mb-6">
|
|
<ServiceForm />
|
|
</div>
|
|
{services.length === 0 ? (
|
|
<p className="text-sm text-[#71717a]">Nessun servizio nel catalogo. Aggiungi il primo servizio qui sopra.</p>
|
|
) : (
|
|
<ServiceTable services={services} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
</interfaces>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Rewrite ServiceTable as database-view (inline edit, tags, quick-add, active/inactive split)</name>
|
|
<files>src/components/admin/catalog/ServiceTable.tsx</files>
|
|
<read_first>
|
|
src/components/admin/catalog/ServiceTable.tsx (current full file, 174 lines — note the existing column order: Nome, Descrizione, Categoria, Prezzo, Stato, Azioni; the price formatting at lines 124-125; the opacity-50 inactive styling at line 114)
|
|
src/components/ui/editable-cell.tsx (Plan 03 output — EditableCellProps)
|
|
src/components/ui/tag-multi-select.tsx (Plan 03 output — TagMultiSelectProps)
|
|
src/app/admin/catalog/actions.ts (Plan 02 output — updateServiceField, quickAddService signatures)
|
|
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-11 description column, D-12 quick-add row, D-13 inactive divider, D-14 no actions column)
|
|
.planning/DESIGN-SYSTEM.md (row height ~40px, no vertical borders, sticky header, hover bg-muted/50)
|
|
</read_first>
|
|
<behavior>
|
|
- Test 1 (columns): table header has exactly 6 columns: Nome, Descrizione, Categoria, Prezzo, Tag, Stato — NO "Azioni" column
|
|
- Test 2 (inline edit, name): clicking the Nome cell of a service renders `EditableCell` with `type="text"`, `required=true`; saving calls `updateServiceField(service.id, "name", value)`
|
|
- Test 3 (inline edit, description): Descrizione cell uses `EditableCell` with `type="textarea"`, full-width, truncated with `line-clamp-2` in display mode (D-11)
|
|
- Test 4 (inline edit, category): Categoria cell uses `EditableCell` with `type="text"`, not required
|
|
- Test 5 (inline edit, price): Prezzo cell uses `EditableCell` with `type="number"` and `formatDisplay` rendering `€{toLocaleString("it-IT", {minimumFractionDigits: 2})}`; saving calls `updateServiceField(service.id, "unit_price", value)`
|
|
- Test 6 (tags column): Tag cell renders `TagMultiSelect` with `tags={service.tags}` and `serviceId={service.id}`
|
|
- Test 7 (status toggle): Stato cell uses `EditableCell` with `type="toggle"`, value `service.active ? "true" : "false"`; saving calls `updateServiceField(service.id, "active", value)`
|
|
- Test 8 (quick-add row): a row at the bottom of the active-services section has a borderless `Input` with placeholder "+ Aggiungi servizio"; pressing Enter with non-empty text calls `quickAddService(name)`, clears the input, and other cells in that row are empty (`colSpan` or empty `<td>`s)
|
|
- Test 9 (active/inactive split): services are partitioned into `activeServices` (active=true) and `inactiveServices` (active=false); active services render first (in `services.name asc` order from the query), then a divider row with text "Servizi disattivati", then inactive services with `opacity-50` applied to the row
|
|
- Test 10 (empty state): if `services.length === 0`, render a message instead of an empty table (handled by `page.tsx`, but `ServiceTable` should not crash on `services=[]`)
|
|
- Test 11 (refresh after save): every `onSave`/`onTagsChanged` callback calls `router.refresh()` after the server action resolves
|
|
- Test 12 (row styling): each `<tr>` has `border-b border-[#e5e7eb]` (no vertical borders), `hover:bg-[#f9f9f9] transition-colors duration-150`
|
|
</behavior>
|
|
<action>
|
|
Replace the entire contents of `src/components/admin/catalog/ServiceTable.tsx` with:
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useState, useTransition } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import { Input } from "@/components/ui/input";
|
|
import { EditableCell } from "@/components/ui/editable-cell";
|
|
import { TagMultiSelect } from "@/components/ui/tag-multi-select";
|
|
import { updateServiceField, quickAddService } from "@/app/admin/catalog/actions";
|
|
import type { ServiceWithTags } from "@/lib/admin-queries";
|
|
|
|
function formatPrice(raw: string): string {
|
|
const num = parseFloat(raw);
|
|
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
|
}
|
|
|
|
function ServiceRow({ service }: { service: ServiceWithTags }) {
|
|
const router = useRouter();
|
|
const [, startTransition] = useTransition();
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
function saveField(
|
|
field: "name" | "description" | "category" | "unit_price" | "active",
|
|
value: string
|
|
) {
|
|
setError(null);
|
|
startTransition(async () => {
|
|
try {
|
|
await updateServiceField(service.id, field, value);
|
|
router.refresh();
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<tr
|
|
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
|
|
!service.active ? "opacity-50" : ""
|
|
}`}
|
|
>
|
|
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
|
|
<EditableCell
|
|
value={service.name}
|
|
type="text"
|
|
required
|
|
onSave={(v) => saveField("name", v)}
|
|
/>
|
|
</td>
|
|
<td className="py-2 px-3 text-[#71717a] min-w-[220px]">
|
|
<EditableCell
|
|
value={service.description ?? ""}
|
|
type="textarea"
|
|
placeholder="Descrizione..."
|
|
onSave={(v) => saveField("description", v)}
|
|
/>
|
|
</td>
|
|
<td className="py-2 px-3 text-[#71717a] min-w-[120px]">
|
|
<EditableCell
|
|
value={service.category ?? ""}
|
|
type="text"
|
|
placeholder="Categoria..."
|
|
onSave={(v) => saveField("category", v)}
|
|
/>
|
|
</td>
|
|
<td className="py-2 px-3 tabular-nums min-w-[100px]">
|
|
<EditableCell
|
|
value={service.unit_price}
|
|
type="number"
|
|
formatDisplay={formatPrice}
|
|
onSave={(v) => saveField("unit_price", v)}
|
|
/>
|
|
</td>
|
|
<td className="py-2 px-3 min-w-[180px]">
|
|
<TagMultiSelect
|
|
tags={service.tags}
|
|
serviceId={service.id}
|
|
onTagsChanged={() => router.refresh()}
|
|
/>
|
|
</td>
|
|
<td className="py-2 px-3 min-w-[100px]">
|
|
<EditableCell
|
|
value={service.active ? "true" : "false"}
|
|
type="toggle"
|
|
onSave={(v) => saveField("active", v)}
|
|
/>
|
|
</td>
|
|
{error && (
|
|
<td className="py-1 px-3 text-xs text-red-600" colSpan={6}>
|
|
{error}
|
|
</td>
|
|
)}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
function QuickAddRow() {
|
|
const [name, setName] = useState("");
|
|
const [, startTransition] = useTransition();
|
|
const [error, setError] = useState<string | null>(null);
|
|
const router = useRouter();
|
|
|
|
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
|
if (e.key !== "Enter") return;
|
|
const trimmed = name.trim();
|
|
if (!trimmed) return;
|
|
e.preventDefault();
|
|
setError(null);
|
|
startTransition(async () => {
|
|
try {
|
|
await quickAddService(trimmed);
|
|
setName("");
|
|
router.refresh();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
|
|
}
|
|
});
|
|
}
|
|
|
|
return (
|
|
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
|
<td className="py-2 px-3">
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="+ Aggiungi servizio"
|
|
className="border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary"
|
|
/>
|
|
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
|
</td>
|
|
<td colSpan={5}></td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
const COLUMN_HEADERS = ["Nome", "Descrizione", "Categoria", "Prezzo", "Tag", "Stato"];
|
|
|
|
export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
|
|
const activeServices = services.filter((s) => s.active);
|
|
const inactiveServices = services.filter((s) => !s.active);
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
|
|
<tr>
|
|
{COLUMN_HEADERS.map((header) => (
|
|
<th
|
|
key={header}
|
|
className="text-left py-2 px-3 font-semibold text-[#71717a]"
|
|
>
|
|
{header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{activeServices.map((s) => (
|
|
<ServiceRow key={s.id} service={s} />
|
|
))}
|
|
|
|
<QuickAddRow />
|
|
|
|
{inactiveServices.length > 0 && (
|
|
<>
|
|
<tr className="border-b border-t-2 border-t-[#e5e7eb] border-[#e5e7eb]">
|
|
<td colSpan={6} className="py-1.5 px-3 text-xs font-medium text-[#71717a] uppercase tracking-wide">
|
|
Servizi disattivati
|
|
</td>
|
|
</tr>
|
|
{inactiveServices.map((s) => (
|
|
<ServiceRow key={s.id} service={s} />
|
|
))}
|
|
</>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Notes:
|
|
- Row height target ~40px is achieved via `py-2` (8px top/bottom padding) on `text-sm` cells — close to the 40px spec without being cramped; adjust to `py-1.5` if visual QA in Plan output finds rows too tall.
|
|
- The error message row (`colSpan={6}`) only renders when a save fails — does not affect row height in the success path.
|
|
- `QuickAddRow` is placed between active and inactive services per D-12/D-13 ordering (new services are active by default, so the add row visually belongs with the active group).
|
|
- The "Servizi disattivati" divider uses `border-t-2` for visual separation per D-13, with `uppercase tracking-wide` for a small-caps-like label per DESIGN-SYSTEM.md ("small-caps ok, ALL-CAPS pesante no" — `uppercase` + `text-xs` + `tracking-wide` on a short label is the lightweight interpretation).
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit 2>&1 | grep -i "ServiceTable" ; echo "exit: $?"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `grep -c "EditableCell" src/components/admin/catalog/ServiceTable.tsx` returns >= 5 (name, description, category, price, status)
|
|
- `grep -c "TagMultiSelect" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
|
|
- `grep -c "Azioni" src/components/admin/catalog/ServiceTable.tsx` returns `0` (no actions column)
|
|
- `grep -c "quickAddService" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
|
|
- `grep -c "updateServiceField" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
|
|
- `grep -c "Servizi disattivati" src/components/admin/catalog/ServiceTable.tsx` returns `1`
|
|
- `grep -c "opacity-50" src/components/admin/catalog/ServiceTable.tsx` returns >= 1
|
|
- `grep -c "router.refresh()" src/components/admin/catalog/ServiceTable.tsx` returns >= 2
|
|
- `grep -c "\"Nome\", \"Descrizione\", \"Categoria\", \"Prezzo\", \"Tag\", \"Stato\"" src/components/admin/catalog/ServiceTable.tsx` returns `1`
|
|
- `npx tsc --noEmit` produces zero errors referencing `src/components/admin/catalog/ServiceTable.tsx`
|
|
</acceptance_criteria>
|
|
<done>
|
|
`/admin/catalog` table has 6 columns (no Azioni), every cell is an `EditableCell` or `TagMultiSelect`, a quick-add row creates new services on Enter, inactive services sink below a "Servizi disattivati" divider with `opacity-50`, and all mutations trigger `router.refresh()`. Typecheck passes.
|
|
</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Add client-side search bar to page.tsx, remove ServiceForm</name>
|
|
<files>
|
|
src/app/admin/catalog/page.tsx
|
|
src/components/admin/catalog/ServiceForm.tsx
|
|
</files>
|
|
<read_first>
|
|
src/app/admin/catalog/page.tsx (current full file, 29 lines)
|
|
src/components/admin/catalog/ServiceForm.tsx (current full file, 102 lines — to be deleted/emptied)
|
|
src/components/admin/catalog/ServiceTable.tsx (Task 1 output — ServiceWithTags prop)
|
|
src/components/ui/input.tsx
|
|
.planning/DESIGN-SYSTEM.md (search bar: "input singolo con icona search (Lucide), filtro client-side istantaneo su nome/tag — NO bottone Cerca, NO reload")
|
|
</read_first>
|
|
<behavior>
|
|
- Test 1 (server component fetches data): `page.tsx` remains an async Server Component calling `getAllServices()` — returns `ServiceWithTags[]`
|
|
- Test 2 (client filter component): a new client component (`CatalogSearch` or inline in a client wrapper) holds the search query state and filters the `services` array before passing to `ServiceTable`
|
|
- Test 3 (filter match): typing a query filters services where `service.name` OR any `service.tags[]` entry contains the query (case-insensitive substring match)
|
|
- Test 4 (empty query): empty search input shows all services (active+inactive split unaffected)
|
|
- Test 5 (no reload): filtering happens via React state — no `router.push`/`router.refresh`/form submission
|
|
- Test 6 (ServiceForm removed): `page.tsx` no longer imports or renders `ServiceForm`; `ServiceForm.tsx` is deleted (its quick-add functionality is now in `ServiceTable`'s `QuickAddRow`)
|
|
- Test 7 (empty catalog message): if `getAllServices()` returns `[]`, show the existing "Nessun servizio..." message (still works even with 0 services, since `ServiceTable` itself renders fine with an empty array but the page-level empty state is friendlier for first-run)
|
|
</behavior>
|
|
<action>
|
|
1. Delete `src/components/admin/catalog/ServiceForm.tsx` entirely (its "+ Aggiungi servizio" quick-add is now `QuickAddRow` inside `ServiceTable`, and its full-form fields — description/category/price at creation time — are no longer needed since D-12 quick-add creates with `unit_price=0` and the user fills in the rest inline immediately after).
|
|
|
|
2. Since filtering needs client-side state, create the filter UI as a client component co-located in `page.tsx`'s directory. Create `src/app/admin/catalog/CatalogSearch.tsx`:
|
|
|
|
```typescript
|
|
"use client";
|
|
|
|
import { useState, useMemo } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Search } from "lucide-react";
|
|
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
|
import type { ServiceWithTags } from "@/lib/admin-queries";
|
|
|
|
export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
|
|
const [query, setQuery] = useState("");
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return services;
|
|
return services.filter((s) => {
|
|
if (s.name.toLowerCase().includes(q)) return true;
|
|
return s.tags.some((tag) => tag.toLowerCase().includes(q));
|
|
});
|
|
}, [services, query]);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="relative max-w-sm">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
|
|
<Input
|
|
type="text"
|
|
placeholder="Cerca per nome o tag..."
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
className="pl-9 h-9"
|
|
/>
|
|
</div>
|
|
<ServiceTable services={filtered} />
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
3. Replace `src/app/admin/catalog/page.tsx` with:
|
|
|
|
```typescript
|
|
import { getAllServices } from "@/lib/admin-queries";
|
|
import { CatalogSearch } from "./CatalogSearch";
|
|
|
|
export const revalidate = 0;
|
|
|
|
export default async function CatalogPage() {
|
|
const services = await getAllServices();
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
|
</div>
|
|
|
|
{services.length === 0 ? (
|
|
<p className="text-sm text-[#71717a]">
|
|
Nessun servizio nel catalogo. Scrivi un nome nella riga in fondo alla tabella e premi invio per crearne uno.
|
|
</p>
|
|
) : (
|
|
<CatalogSearch services={services} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
Note: when `services.length === 0`, `CatalogSearch`/`ServiceTable` is not rendered, so the quick-add row (which lives inside `ServiceTable`) is not visible on a completely empty catalog. This matches the existing "Nessun servizio" UX from before Phase 11, but the message now points the user at the table's quick-add row. If this is undesirable in practice (catalog is currently non-empty per Phase 7 migration, so this is an edge case), it can be revisited — not blocking for Phase 11 success criteria, which describe filtering/quick-add behavior on a populated catalog.
|
|
</action>
|
|
<verify>
|
|
<automated>npx tsc --noEmit 2>&1 | grep -i "catalog" ; echo "exit: $?"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `test -f src/components/admin/catalog/ServiceForm.tsx; echo $?` returns `1` (file deleted)
|
|
- `grep -c "ServiceForm" src/app/admin/catalog/page.tsx` returns `0`
|
|
- `test -f src/app/admin/catalog/CatalogSearch.tsx; echo $?` returns `0`
|
|
- `grep -c "export function CatalogSearch" src/app/admin/catalog/CatalogSearch.tsx` returns `1`
|
|
- `grep -c "useState\|useMemo" src/app/admin/catalog/CatalogSearch.tsx` returns >= 2
|
|
- `grep -c "s.tags.some" src/app/admin/catalog/CatalogSearch.tsx` returns `1`
|
|
- `grep -c "router.push\|router.refresh\|<form" src/app/admin/catalog/CatalogSearch.tsx` returns `0` (purely client-state filtering, no navigation/reload)
|
|
- `grep -c "getAllServices" src/app/admin/catalog/page.tsx` returns `1`
|
|
- `npx tsc --noEmit` produces zero errors referencing `src/app/admin/catalog/`
|
|
- `npx next build` (or `npm run build`) completes without errors related to `/admin/catalog`
|
|
</acceptance_criteria>
|
|
<done>
|
|
`/admin/catalog` has a search input above the table that filters services by name or tag, client-side, with zero reload. `ServiceForm.tsx` is deleted; its create-service UX is fully replaced by `ServiceTable`'s quick-add row. Build succeeds.
|
|
</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|--------------|
|
|
| Admin browser -> ServiceTable/CatalogSearch -> Server Actions | All mutations (`updateServiceField`, `quickAddService`, tag actions via `TagMultiSelect`) flow through Plan 02's admin-gated actions |
|
|
| Client-side search filter | Pure presentational filter over already-fetched `ServiceWithTags[]` — no new data fetched based on query input |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-11-14 | Information Disclosure | `CatalogSearch` client-side filter | accept | All `services` (including inactive ones) are sent to the client regardless of search query — this is the existing behavior (ServiceTable already received the full list pre-Phase-11) and is required for the active/inactive split + instant client-side search (OFFER-10). No new data is exposed: `/admin/catalog` is already behind Auth.js session (middleware admin guard). |
|
|
| T-11-15 | Tampering | `QuickAddRow` repeated Enter presses | mitigate | `startTransition` + the input is not cleared until the action resolves successfully; a failed `quickAddService` call surfaces an inline error without creating a partial row, since the DB insert is atomic. |
|
|
| T-11-16 | Denial of Service | Large catalog (many services) rendered client-side | accept | Catalog size is bounded by manual admin entry (single-admin app, no bulk import in this phase per CONTEXT.md — CSV import is Phase 12). Acceptable for current and near-future scale. |
|
|
|
|
No HIGH-severity unmitigated threats. Authorization for all mutating operations is enforced in Plan 02's server actions, inherited transitively by this plan's UI.
|
|
</threat_model>
|
|
|
|
<verification>
|
|
1. `npx tsc --noEmit` passes with zero errors
|
|
2. `npm run build` (or `npx next build`) completes successfully
|
|
3. Manual smoke test (covered by checkpoint below): `/admin/catalog` renders the database-view table with inline edit, tags, quick-add row, search bar, and active/inactive split
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- OFFER-07: every cell (name, description, category, price, status) is click-to-edit via `EditableCell`, saving on Enter/blur, no modal/reload
|
|
- OFFER-08: tags column uses `TagMultiSelect`, supports creating new tags on the fly
|
|
- OFFER-09: quick-add row at the bottom of the active section creates a new service (unit_price=0) on Enter
|
|
- OFFER-10: search bar filters by name/tag, client-side, instant, no reload
|
|
- D-13/D-14: inactive services sink below a divider with reduced opacity; no "Azioni" column anywhere
|
|
- Build passes (`npm run build`)
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-04-SUMMARY.md`
|
|
</output>
|