feat(catalog): Notion-style shared select fields (tag/pacchetto/categoria/fase)
Turn the catalog into Notion/Airtable select properties: - Tag + Pacchetto: multi-select with a SHARED pool — created values persist and are selectable from a dropdown across all services (no more re-typing) - Categoria + Fase: single-select chips with the same dropdown + create-on-the-fly - Rename an option once -> propagates to every row (renameServiceOption) - Deterministic colors per value (shared option-colors util) - Quick-add row now sets name+description+categoria+fase+prezzo then Enter (active) - Search broadened to name/categoria/fase/tag/pacchetto Data model (additive): tag/pacchetto in polymorphic tags table (entity_type services / services.pacchetto); categoria/fase as single-select columns on services (new: services.fase). Pools derived from distinct values. New: OptionSelect, OptionMultiSelect, option-colors. Removed tag-multi-select. Migration 0007_add_services_fase.sql must be applied before runtime. tsc + eslint + next build clean. CSV bulk import (OFFER-12) stays Phase 12. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
// PRODUCTION DEPLOY NOTE: additive-only (ADD COLUMN IF NOT EXISTS, no drops/truncates).
|
||||||
|
// Per CLAUDE.md Data Safety, apply to the live DB via SSH tunnel / docker exec BEFORE
|
||||||
|
// deploying the schema-dependent catalog code. Idempotent — safe to re-run.
|
||||||
|
// Run: npx tsx scripts/push-11b-fase-column.ts (with DATABASE_URL pointing at the DB)
|
||||||
|
import postgres from "postgres";
|
||||||
|
|
||||||
|
async function push() {
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
if (!databaseUrl) {
|
||||||
|
console.error("DATABASE_URL environment variable is required");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = postgres(databaseUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("Adding services.fase column...");
|
||||||
|
await client`ALTER TABLE services ADD COLUMN IF NOT EXISTS fase text`;
|
||||||
|
console.log("✓ services.fase column ready");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Error pushing migration:", err instanceof Error ? err.message : err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
push();
|
||||||
@@ -4,9 +4,15 @@ import { useState, useMemo } from "react";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
||||||
import type { ServiceWithTags } from "@/lib/admin-queries";
|
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||||
|
|
||||||
export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
|
export function CatalogSearch({
|
||||||
|
services,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
services: ServiceWithTags[];
|
||||||
|
options: CatalogFieldOptions;
|
||||||
|
}) {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
@@ -14,7 +20,11 @@ export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
|
|||||||
if (!q) return services;
|
if (!q) return services;
|
||||||
return services.filter((s) => {
|
return services.filter((s) => {
|
||||||
if (s.name.toLowerCase().includes(q)) return true;
|
if (s.name.toLowerCase().includes(q)) return true;
|
||||||
return s.tags.some((tag) => tag.toLowerCase().includes(q));
|
if (s.category?.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.fase?.toLowerCase().includes(q)) return true;
|
||||||
|
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||||
|
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}, [services, query]);
|
}, [services, query]);
|
||||||
|
|
||||||
@@ -24,13 +34,13 @@ export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
|
|||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Cerca per nome o tag..."
|
placeholder="Cerca per nome, categoria, fase, tag o pacchetto..."
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="pl-9 h-9"
|
className="pl-9 h-9"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<ServiceTable services={filtered} />
|
<ServiceTable services={filtered} options={options} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function toggleServiceActive(serviceId: string, active: boolean) {
|
|||||||
|
|
||||||
// ── Inline-edit, tag, and quick-add actions (Phase 11 database-view) ────────
|
// ── Inline-edit, tag, and quick-add actions (Phase 11 database-view) ────────
|
||||||
|
|
||||||
const EDITABLE_FIELDS = ["name", "description", "category", "unit_price", "active"] as const;
|
const EDITABLE_FIELDS = ["name", "description", "category", "fase", "unit_price", "active"] as const;
|
||||||
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
type EditableField = (typeof EDITABLE_FIELDS)[number];
|
||||||
|
|
||||||
export async function updateServiceField(
|
export async function updateServiceField(
|
||||||
@@ -92,6 +92,9 @@ export async function updateServiceField(
|
|||||||
} else if (fieldName === "category") {
|
} else if (fieldName === "category") {
|
||||||
const s = String(value).trim();
|
const s = String(value).trim();
|
||||||
await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId));
|
await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId));
|
||||||
|
} else if (fieldName === "fase") {
|
||||||
|
const s = String(value).trim();
|
||||||
|
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId));
|
||||||
} else if (fieldName === "unit_price") {
|
} else if (fieldName === "unit_price") {
|
||||||
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50),
|
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50),
|
||||||
// so an admin may type "1.234,50". When a comma is present, treat "." as thousands
|
// so an admin may type "1.234,50". When a comma is present, treat "." as thousands
|
||||||
@@ -112,47 +115,124 @@ export async function updateServiceField(
|
|||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addTagToService(serviceId: string, tagName: string) {
|
// ── Multi-select option fields (Notion-style shared pools) ──────────────────
|
||||||
|
// "tag" and "pacchetto" are stored in the polymorphic `tags` table, scoped by
|
||||||
|
// entity_type so the two pools stay separate.
|
||||||
|
|
||||||
|
const MULTI_ENTITY: Record<MultiSelectField, string> = {
|
||||||
|
tag: "services",
|
||||||
|
pacchetto: "services.pacchetto",
|
||||||
|
};
|
||||||
|
const MULTI_FIELDS = ["tag", "pacchetto"] as const;
|
||||||
|
export type MultiSelectField = (typeof MULTI_FIELDS)[number];
|
||||||
|
|
||||||
|
type SingleSelectField = "categoria" | "fase";
|
||||||
|
|
||||||
|
export async function addServiceOption(
|
||||||
|
field: MultiSelectField,
|
||||||
|
serviceId: string,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const trimmed = tagName.trim();
|
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
|
||||||
if (trimmed.length === 0) throw new Error("Nome tag richiesto");
|
const trimmed = value.trim();
|
||||||
|
if (trimmed.length === 0) throw new Error("Valore richiesto");
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.insert(tags)
|
.insert(tags)
|
||||||
.values({
|
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
|
||||||
entity_type: "services",
|
|
||||||
entity_id: serviceId,
|
|
||||||
name: trimmed,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTagFromService(serviceId: string, tagName: string) {
|
export async function removeServiceOption(
|
||||||
|
field: MultiSelectField,
|
||||||
|
serviceId: string,
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
|
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(tags)
|
.delete(tags)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(tags.entity_type, "services"),
|
eq(tags.entity_type, MULTI_ENTITY[field]),
|
||||||
eq(tags.entity_id, serviceId),
|
eq(tags.entity_id, serviceId),
|
||||||
eq(tags.name, tagName)
|
eq(tags.name, value)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function quickAddService(name: string) {
|
// ── Rename an option everywhere it is used (propagates across all services) ───
|
||||||
|
// Works for both multi-select pools (tag/pacchetto) and single-select columns
|
||||||
|
// (categoria/fase). This is what makes the dropdown options feel persistent &
|
||||||
|
// editable like Notion select properties.
|
||||||
|
|
||||||
|
export async function renameServiceOption(
|
||||||
|
field: MultiSelectField | SingleSelectField,
|
||||||
|
oldValue: string,
|
||||||
|
newValue: string
|
||||||
|
) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const trimmed = name.trim();
|
const next = newValue.trim();
|
||||||
if (trimmed.length === 0) throw new Error("Nome richiesto");
|
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||||
|
if (next === oldValue) return;
|
||||||
|
|
||||||
|
if (field === "tag" || field === "pacchetto") {
|
||||||
|
await db
|
||||||
|
.update(tags)
|
||||||
|
.set({ name: next })
|
||||||
|
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
|
||||||
|
} else if (field === "categoria") {
|
||||||
|
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
|
||||||
|
} else if (field === "fase") {
|
||||||
|
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
|
||||||
|
} else {
|
||||||
|
throw new Error(`Campo non valido: ${field}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Quick-add: create a service from the bottom row with all scalar fields set
|
||||||
|
// and active=true in one Enter. Tags/pacchetto are assigned afterwards (they
|
||||||
|
// need the new row's id). Only `name` is required.
|
||||||
|
|
||||||
|
export type QuickAddPayload = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
fase?: string;
|
||||||
|
unit_price?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function quickAddService(payload: QuickAddPayload | string) {
|
||||||
|
await requireAdmin();
|
||||||
|
// Back-compat: accept a bare name string.
|
||||||
|
const data: QuickAddPayload = typeof payload === "string" ? { name: payload } : payload;
|
||||||
|
|
||||||
|
const name = data.name.trim();
|
||||||
|
if (name.length === 0) throw new Error("Nome richiesto");
|
||||||
|
|
||||||
|
let unit_price = "0.00";
|
||||||
|
if (data.unit_price && data.unit_price.trim().length > 0) {
|
||||||
|
const raw = data.unit_price.trim();
|
||||||
|
const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw;
|
||||||
|
const num = Number(normalized);
|
||||||
|
if (!Number.isFinite(num) || num < 0) throw new Error("Prezzo invalido");
|
||||||
|
unit_price = num.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
await db.insert(services).values({
|
await db.insert(services).values({
|
||||||
name: trimmed,
|
name,
|
||||||
unit_price: "0.00",
|
description: data.description?.trim() || null,
|
||||||
|
category: data.category?.trim() || null,
|
||||||
|
fase: data.fase?.trim() || null,
|
||||||
|
unit_price,
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { getAllServices } from "@/lib/admin-queries";
|
import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries";
|
||||||
import { CatalogSearch } from "./CatalogSearch";
|
import { CatalogSearch } from "./CatalogSearch";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function CatalogPage() {
|
export default async function CatalogPage() {
|
||||||
const services = await getAllServices();
|
const [services, options] = await Promise.all([
|
||||||
|
getAllServices(),
|
||||||
|
getCatalogFieldOptions(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -12,13 +15,7 @@ export default async function CatalogPage() {
|
|||||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{services.length === 0 ? (
|
<CatalogSearch services={services} options={options} />
|
||||||
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,28 +4,50 @@ import { useState, useTransition } from "react";
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { EditableCell } from "@/components/ui/editable-cell";
|
import { EditableCell } from "@/components/ui/editable-cell";
|
||||||
import { TagMultiSelect } from "@/components/ui/tag-multi-select";
|
import { OptionSelect } from "@/components/ui/option-select";
|
||||||
import { updateServiceField, quickAddService } from "@/app/admin/catalog/actions";
|
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||||
import type { ServiceWithTags } from "@/lib/admin-queries";
|
import {
|
||||||
|
updateServiceField,
|
||||||
|
quickAddService,
|
||||||
|
addServiceOption,
|
||||||
|
removeServiceOption,
|
||||||
|
renameServiceOption,
|
||||||
|
} from "@/app/admin/catalog/actions";
|
||||||
|
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||||
|
|
||||||
function formatPrice(raw: string): string {
|
function formatPrice(raw: string): string {
|
||||||
const num = parseFloat(raw);
|
const num = parseFloat(raw);
|
||||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ServiceRow({ service }: { service: ServiceWithTags }) {
|
const COLUMN_HEADERS = [
|
||||||
|
"Nome",
|
||||||
|
"Descrizione",
|
||||||
|
"Categoria",
|
||||||
|
"Fase",
|
||||||
|
"Tag",
|
||||||
|
"Pacchetto",
|
||||||
|
"Prezzo",
|
||||||
|
"Stato",
|
||||||
|
];
|
||||||
|
const COL_COUNT = COLUMN_HEADERS.length;
|
||||||
|
|
||||||
|
function ServiceRow({
|
||||||
|
service,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
service: ServiceWithTags;
|
||||||
|
options: CatalogFieldOptions;
|
||||||
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
function saveField(
|
function run(fn: () => Promise<unknown>) {
|
||||||
field: "name" | "description" | "category" | "unit_price" | "active",
|
|
||||||
value: string
|
|
||||||
) {
|
|
||||||
setError(null);
|
setError(null);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await updateServiceField(service.id, field, value);
|
await fn();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||||
@@ -45,23 +67,49 @@ function ServiceRow({ service }: { service: ServiceWithTags }) {
|
|||||||
value={service.name}
|
value={service.name}
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
onSave={(v) => saveField("name", v)}
|
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-[#71717a] min-w-[220px]">
|
<td className="py-2 px-3 text-[#71717a] min-w-[200px]">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={service.description ?? ""}
|
value={service.description ?? ""}
|
||||||
type="textarea"
|
type="textarea"
|
||||||
placeholder="Descrizione..."
|
placeholder="Descrizione..."
|
||||||
onSave={(v) => saveField("description", v)}
|
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 text-[#71717a] min-w-[120px]">
|
<td className="py-2 px-3 min-w-[140px]">
|
||||||
<EditableCell
|
<OptionSelect
|
||||||
value={service.category ?? ""}
|
value={service.category}
|
||||||
type="text"
|
options={options.categoria}
|
||||||
placeholder="Categoria..."
|
onChange={(v) => run(() => updateServiceField(service.id, "category", v ?? ""))}
|
||||||
onSave={(v) => saveField("category", v)}
|
onRename={(o, n) => run(() => renameServiceOption("categoria", o, n))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[140px]">
|
||||||
|
<OptionSelect
|
||||||
|
value={service.fase}
|
||||||
|
options={options.fase}
|
||||||
|
onChange={(v) => run(() => updateServiceField(service.id, "fase", v ?? ""))}
|
||||||
|
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[180px]">
|
||||||
|
<OptionMultiSelect
|
||||||
|
values={service.tags}
|
||||||
|
options={options.tag}
|
||||||
|
onAdd={(v) => run(() => addServiceOption("tag", service.id, v))}
|
||||||
|
onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))}
|
||||||
|
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 min-w-[180px]">
|
||||||
|
<OptionMultiSelect
|
||||||
|
values={service.pacchetto}
|
||||||
|
options={options.pacchetto}
|
||||||
|
onAdd={(v) => run(() => addServiceOption("pacchetto", service.id, v))}
|
||||||
|
onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))}
|
||||||
|
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 px-3 tabular-nums min-w-[100px]">
|
<td className="py-2 px-3 tabular-nums min-w-[100px]">
|
||||||
@@ -69,27 +117,20 @@ function ServiceRow({ service }: { service: ServiceWithTags }) {
|
|||||||
value={service.unit_price}
|
value={service.unit_price}
|
||||||
type="number"
|
type="number"
|
||||||
formatDisplay={formatPrice}
|
formatDisplay={formatPrice}
|
||||||
onSave={(v) => saveField("unit_price", v)}
|
onSave={(v) => run(() => updateServiceField(service.id, "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>
|
||||||
<td className="py-2 px-3 min-w-[100px]">
|
<td className="py-2 px-3 min-w-[100px]">
|
||||||
<EditableCell
|
<EditableCell
|
||||||
value={service.active ? "true" : "false"}
|
value={service.active ? "true" : "false"}
|
||||||
type="toggle"
|
type="toggle"
|
||||||
onSave={(v) => saveField("active", v)}
|
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{error && (
|
{error && (
|
||||||
<tr>
|
<tr>
|
||||||
<td className="py-1 px-3 text-xs text-red-600" colSpan={6}>
|
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||||
{error}
|
{error}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -98,22 +139,34 @@ function ServiceRow({ service }: { service: ServiceWithTags }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuickAddRow() {
|
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [category, setCategory] = useState("");
|
||||||
|
const [fase, setFase] = useState("");
|
||||||
|
const [price, setPrice] = useState("");
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
function submit() {
|
||||||
if (e.key !== "Enter") return;
|
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
e.preventDefault();
|
|
||||||
setError(null);
|
setError(null);
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
try {
|
try {
|
||||||
await quickAddService(trimmed);
|
await quickAddService({
|
||||||
|
name: trimmed,
|
||||||
|
description: description || undefined,
|
||||||
|
category: category || undefined,
|
||||||
|
fase: fase || undefined,
|
||||||
|
unit_price: price || undefined,
|
||||||
|
});
|
||||||
setName("");
|
setName("");
|
||||||
|
setDescription("");
|
||||||
|
setCategory("");
|
||||||
|
setFase("");
|
||||||
|
setPrice("");
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
|
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
|
||||||
@@ -121,26 +174,100 @@ function QuickAddRow() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cellInput =
|
||||||
|
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
placeholder="+ Aggiungi servizio"
|
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"
|
className={cellInput}
|
||||||
/>
|
/>
|
||||||
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
|
||||||
</td>
|
</td>
|
||||||
<td colSpan={5}></td>
|
<td className="py-2 px-3">
|
||||||
|
<Input
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Descrizione..."
|
||||||
|
className={cellInput}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<Input
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Categoria..."
|
||||||
|
list="catalog-categoria-pool"
|
||||||
|
className={cellInput}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<Input
|
||||||
|
value={fase}
|
||||||
|
onChange={(e) => setFase(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Fase..."
|
||||||
|
list="catalog-fase-pool"
|
||||||
|
className={cellInput}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
|
||||||
|
Tag e pacchetto dopo la creazione
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<Input
|
||||||
|
value={price}
|
||||||
|
onChange={(e) => setPrice(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="0,00"
|
||||||
|
inputMode="decimal"
|
||||||
|
className={`${cellInput} tabular-nums`}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio ↵</td>
|
||||||
|
<datalist id="catalog-categoria-pool">
|
||||||
|
{options.categoria.map((c) => (
|
||||||
|
<option key={c} value={c} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
|
<datalist id="catalog-fase-pool">
|
||||||
|
{options.fase.map((f) => (
|
||||||
|
<option key={f} value={f} />
|
||||||
|
))}
|
||||||
|
</datalist>
|
||||||
</tr>
|
</tr>
|
||||||
|
{error && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||||
|
{error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const COLUMN_HEADERS = ["Nome", "Descrizione", "Categoria", "Prezzo", "Tag", "Stato"];
|
export function ServiceTable({
|
||||||
|
services,
|
||||||
export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
|
options,
|
||||||
|
}: {
|
||||||
|
services: ServiceWithTags[];
|
||||||
|
options: CatalogFieldOptions;
|
||||||
|
}) {
|
||||||
const activeServices = services.filter((s) => s.active);
|
const activeServices = services.filter((s) => s.active);
|
||||||
const inactiveServices = services.filter((s) => !s.active);
|
const inactiveServices = services.filter((s) => !s.active);
|
||||||
|
|
||||||
@@ -161,21 +288,22 @@ export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{activeServices.map((s) => (
|
{activeServices.map((service) => (
|
||||||
<ServiceRow key={s.id} service={s} />
|
<ServiceRow key={service.id} service={service} options={options} />
|
||||||
))}
|
))}
|
||||||
|
<QuickAddRow options={options} />
|
||||||
<QuickAddRow />
|
|
||||||
|
|
||||||
{inactiveServices.length > 0 && (
|
{inactiveServices.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<tr className="border-b border-t-2 border-t-[#e5e7eb] border-[#e5e7eb]">
|
<tr className="bg-[#fafafa]">
|
||||||
<td colSpan={6} className="py-1.5 px-3 text-xs font-medium text-[#71717a] uppercase tracking-wide">
|
<td
|
||||||
|
colSpan={COL_COUNT}
|
||||||
|
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
|
||||||
|
>
|
||||||
Servizi disattivati
|
Servizi disattivati
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{inactiveServices.map((s) => (
|
{inactiveServices.map((service) => (
|
||||||
<ServiceRow key={s.id} service={s} />
|
<ServiceRow key={service.id} service={service} options={options} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// Deterministic option-name -> color, shared by every select/multi-select field
|
||||||
|
// (D-07: no stored color column — colour is derived from the value's hash).
|
||||||
|
// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white.
|
||||||
|
export const OPTION_COLORS = [
|
||||||
|
"bg-blue-100 text-blue-900",
|
||||||
|
"bg-green-100 text-green-900",
|
||||||
|
"bg-purple-100 text-purple-900",
|
||||||
|
"bg-pink-100 text-pink-900",
|
||||||
|
"bg-amber-100 text-amber-900",
|
||||||
|
"bg-teal-100 text-teal-900",
|
||||||
|
"bg-orange-100 text-orange-900",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getOptionColor(value: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < value.length; i++) {
|
||||||
|
hash = (hash << 5) - hash + value.charCodeAt(i);
|
||||||
|
hash |= 0; // 32-bit int
|
||||||
|
}
|
||||||
|
return OPTION_COLORS[Math.abs(hash) % OPTION_COLORS.length];
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Check, X, Plus, Pencil } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getOptionColor } from "@/components/ui/option-colors";
|
||||||
|
|
||||||
|
export interface OptionMultiSelectProps {
|
||||||
|
values: string[];
|
||||||
|
options: string[];
|
||||||
|
onAdd: (value: string) => void;
|
||||||
|
onRemove: (value: string) => void;
|
||||||
|
onRename?: (oldValue: string, newValue: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionMultiSelect({
|
||||||
|
values,
|
||||||
|
options,
|
||||||
|
onAdd,
|
||||||
|
onRemove,
|
||||||
|
onRename,
|
||||||
|
placeholder = "Cerca o crea...",
|
||||||
|
disabled,
|
||||||
|
}: OptionMultiSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setRenaming(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) inputRef.current.focus();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const selected = new Set(values);
|
||||||
|
// Union of the shared pool + anything already on this row, filtered by query.
|
||||||
|
const pool = Array.from(new Set([...options, ...values])).sort((a, b) =>
|
||||||
|
a.localeCompare(b, "it")
|
||||||
|
);
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const filtered = q ? pool.filter((o) => o.toLowerCase().includes(q)) : pool;
|
||||||
|
const exactMatch = pool.some((o) => o.toLowerCase() === q);
|
||||||
|
|
||||||
|
function toggle(value: string) {
|
||||||
|
if (selected.has(value)) onRemove(value);
|
||||||
|
else onAdd(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFromQuery() {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (!selected.has(trimmed)) onAdd(trimmed);
|
||||||
|
setQuery("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRename(oldValue: string) {
|
||||||
|
const next = renameValue.trim();
|
||||||
|
setRenaming(null);
|
||||||
|
if (next && next !== oldValue) onRename?.(oldValue, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full min-w-[140px]">
|
||||||
|
<div
|
||||||
|
onClick={() => !disabled && setIsOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"flex flex-wrap gap-1 items-center px-2 py-1 rounded transition-colors duration-150 min-h-[28px]",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[#f0f0f0]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{values.length === 0 ? (
|
||||||
|
<span className="text-xs text-[#71717a]">—</span>
|
||||||
|
) : (
|
||||||
|
values.map((v) => (
|
||||||
|
<Badge
|
||||||
|
key={v}
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
getOptionColor(v),
|
||||||
|
"border-transparent text-xs px-2 py-0.5 gap-1 font-medium"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{v}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemove(v);
|
||||||
|
}}
|
||||||
|
className="hover:opacity-70"
|
||||||
|
aria-label={`Rimuovi ${v}`}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[220px] max-h-[280px] overflow-y-auto">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!exactMatch) createFromQuery();
|
||||||
|
else if (filtered.length === 1) toggle(filtered[0]);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm h-8 mb-1.5 ring-1 ring-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{filtered.map((opt) =>
|
||||||
|
renaming === opt ? (
|
||||||
|
<Input
|
||||||
|
key={opt}
|
||||||
|
autoFocus
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={() => commitRename(opt)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commitRename(opt);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setRenaming(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm h-7"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={opt}
|
||||||
|
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-[#f5f5f5] group"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(opt)}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
<span className="w-3.5 flex-shrink-0">
|
||||||
|
{selected.has(opt) && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
getOptionColor(opt),
|
||||||
|
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
{onRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setRenameValue(opt);
|
||||||
|
setRenaming(opt);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-[#71717a] hover:text-[#1a1a1a] p-0.5"
|
||||||
|
aria-label={`Rinomina ${opt}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q && !exactMatch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={createFromQuery}
|
||||||
|
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-[#f5f5f5] text-left text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 text-[#1A463C]" />
|
||||||
|
Crea <span className="font-medium">«{query.trim()}»</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.length === 0 && !q && (
|
||||||
|
<p className="text-xs text-[#71717a] px-1.5 py-1">Nessuna opzione ancora</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Check, Plus, Pencil, X } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { getOptionColor } from "@/components/ui/option-colors";
|
||||||
|
|
||||||
|
export interface OptionSelectProps {
|
||||||
|
value: string | null;
|
||||||
|
options: string[];
|
||||||
|
onChange: (value: string | null) => void;
|
||||||
|
onRename?: (oldValue: string, newValue: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OptionSelect({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
onRename,
|
||||||
|
placeholder = "Cerca o crea...",
|
||||||
|
disabled,
|
||||||
|
}: OptionSelectProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
setRenaming(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && inputRef.current) inputRef.current.focus();
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const pool = Array.from(new Set([...options, ...(value ? [value] : [])])).sort((a, b) =>
|
||||||
|
a.localeCompare(b, "it")
|
||||||
|
);
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const filtered = q ? pool.filter((o) => o.toLowerCase().includes(q)) : pool;
|
||||||
|
const exactMatch = pool.some((o) => o.toLowerCase() === q);
|
||||||
|
|
||||||
|
function select(v: string) {
|
||||||
|
onChange(v);
|
||||||
|
setQuery("");
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFromQuery() {
|
||||||
|
const trimmed = query.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
select(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitRename(oldValue: string) {
|
||||||
|
const next = renameValue.trim();
|
||||||
|
setRenaming(null);
|
||||||
|
if (next && next !== oldValue) onRename?.(oldValue, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative w-full min-w-[120px]">
|
||||||
|
<div
|
||||||
|
onClick={() => !disabled && setIsOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px]",
|
||||||
|
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[#f0f0f0]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
getOptionColor(value),
|
||||||
|
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-[#71717a]">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[200px] max-h-[280px] overflow-y-auto">
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!exactMatch) createFromQuery();
|
||||||
|
else if (filtered.length === 1) select(filtered[0]);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm h-8 mb-1.5 ring-1 ring-primary"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
{value && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(null);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left text-xs text-[#71717a]"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" /> Rimuovi
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filtered.map((opt) =>
|
||||||
|
renaming === opt ? (
|
||||||
|
<Input
|
||||||
|
key={opt}
|
||||||
|
autoFocus
|
||||||
|
value={renameValue}
|
||||||
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
|
onBlur={() => commitRename(opt)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
commitRename(opt);
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setRenaming(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-sm h-7"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
key={opt}
|
||||||
|
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-[#f5f5f5] group"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => select(opt)}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
<span className="w-3.5 flex-shrink-0">
|
||||||
|
{value === opt && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
getOptionColor(opt),
|
||||||
|
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
{onRename && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setRenameValue(opt);
|
||||||
|
setRenaming(opt);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-[#71717a] hover:text-[#1a1a1a] p-0.5"
|
||||||
|
aria-label={`Rinomina ${opt}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{q && !exactMatch && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={createFromQuery}
|
||||||
|
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-[#f5f5f5] text-left text-sm"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5 text-[#1A463C]" />
|
||||||
|
Crea <span className="font-medium">«{query.trim()}»</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useState, useRef, useEffect, useTransition } from "react";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { X, Plus } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { addTagToService, removeTagFromService } from "@/app/admin/catalog/actions";
|
|
||||||
|
|
||||||
// D-07: deterministic name -> color index, no stored `color` column.
|
|
||||||
// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white.
|
|
||||||
const TAG_COLORS = [
|
|
||||||
"bg-blue-100 text-blue-900",
|
|
||||||
"bg-green-100 text-green-900",
|
|
||||||
"bg-purple-100 text-purple-900",
|
|
||||||
"bg-pink-100 text-pink-900",
|
|
||||||
"bg-amber-100 text-amber-900",
|
|
||||||
"bg-teal-100 text-teal-900",
|
|
||||||
"bg-orange-100 text-orange-900",
|
|
||||||
];
|
|
||||||
|
|
||||||
export function getTagColorIndex(name: string): number {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < name.length; i++) {
|
|
||||||
hash = (hash << 5) - hash + name.charCodeAt(i);
|
|
||||||
hash |= 0; // 32-bit int
|
|
||||||
}
|
|
||||||
return Math.abs(hash) % TAG_COLORS.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TagMultiSelectProps {
|
|
||||||
tags: string[];
|
|
||||||
serviceId: string;
|
|
||||||
onTagsChanged?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TagMultiSelect({ tags, serviceId, onTagsChanged }: TagMultiSelectProps) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [newTag, setNewTag] = useState("");
|
|
||||||
const [isPending, startTransition] = useTransition();
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
function handleClickOutside(e: MouseEvent) {
|
|
||||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen && inputRef.current) {
|
|
||||||
inputRef.current.focus();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
function handleAddTag() {
|
|
||||||
const trimmed = newTag.trim();
|
|
||||||
if (!trimmed) return;
|
|
||||||
setError(null);
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
await addTagToService(serviceId, trimmed);
|
|
||||||
setNewTag("");
|
|
||||||
onTagsChanged?.();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemoveTag(tagName: string) {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
await removeTagFromService(serviceId, tagName);
|
|
||||||
onTagsChanged?.();
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Errore nella rimozione");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={containerRef} className="relative w-full min-w-[140px]">
|
|
||||||
<div
|
|
||||||
onClick={() => setIsOpen((v) => !v)}
|
|
||||||
className="flex flex-wrap gap-1 items-center px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150 min-h-[28px]"
|
|
||||||
>
|
|
||||||
{tags.length === 0 ? (
|
|
||||||
<span className="text-xs text-[#71717a]">—</span>
|
|
||||||
) : (
|
|
||||||
tags.map((tag) => (
|
|
||||||
<Badge
|
|
||||||
key={tag}
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
TAG_COLORS[getTagColorIndex(tag)],
|
|
||||||
"border-transparent text-xs px-2 py-0.5 gap-1 font-medium"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handleRemoveTag(tag);
|
|
||||||
}}
|
|
||||||
disabled={isPending}
|
|
||||||
className="hover:opacity-70 disabled:opacity-30"
|
|
||||||
aria-label={`Rimuovi tag ${tag}`}
|
|
||||||
>
|
|
||||||
<X className="h-3 w-3" />
|
|
||||||
</button>
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-2 z-10 min-w-[200px]">
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
placeholder="Nome tag..."
|
|
||||||
value={newTag}
|
|
||||||
onChange={(e) => setNewTag(e.target.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleAddTag();
|
|
||||||
}
|
|
||||||
if (e.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsOpen(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-sm h-8 ring-1 ring-primary"
|
|
||||||
disabled={isPending}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddTag}
|
|
||||||
disabled={isPending || !newTag.trim()}
|
|
||||||
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90 h-8 px-2"
|
|
||||||
>
|
|
||||||
+
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Additive: single-select "fase" option on services (Notion-style catalog).
|
||||||
|
-- No drops/truncates. Safe to re-run.
|
||||||
|
ALTER TABLE services ADD COLUMN IF NOT EXISTS fase text;
|
||||||
+2
-1
@@ -216,7 +216,8 @@ export const services = pgTable("services", {
|
|||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
|
||||||
category: text("category"),
|
category: text("category"), // single-select option (shared pool derived from distinct values)
|
||||||
|
fase: text("fase"), // single-select option — offer lifecycle phase (Notion-style)
|
||||||
active: boolean("active").notNull().default(true),
|
active: boolean("active").notNull().default(true),
|
||||||
migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null (new rows after Phase 7)
|
migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null (new rows after Phase 7)
|
||||||
migrated_id: text("migrated_id"), // original id from source table, null for new rows
|
migrated_id: text("migrated_id"), // original id from source table, null for new rows
|
||||||
|
|||||||
@@ -357,9 +357,15 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ServiceWithTags — services + assigned tag names (Phase 11 database-view) ──
|
// ── ServiceWithTags — services + multi-select options (Phase 11 database-view) ─
|
||||||
|
// "tag" and "pacchetto" are multi-select pools stored in the polymorphic `tags`
|
||||||
|
// table, distinguished by entity_type: "services" (tag) | "services.pacchetto".
|
||||||
|
// "category" and "fase" are single-select values stored directly on the row.
|
||||||
|
|
||||||
export type ServiceWithTags = Service & { tags: string[] };
|
const TAG_ENTITY = "services";
|
||||||
|
const PACCHETTO_ENTITY = "services.pacchetto";
|
||||||
|
|
||||||
|
export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] };
|
||||||
|
|
||||||
export async function getAllServices(): Promise<ServiceWithTags[]> {
|
export async function getAllServices(): Promise<ServiceWithTags[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
@@ -369,32 +375,78 @@ export async function getAllServices(): Promise<ServiceWithTags[]> {
|
|||||||
description: services.description,
|
description: services.description,
|
||||||
unit_price: services.unit_price,
|
unit_price: services.unit_price,
|
||||||
category: services.category,
|
category: services.category,
|
||||||
|
fase: services.fase,
|
||||||
active: services.active,
|
active: services.active,
|
||||||
migrated_from: services.migrated_from,
|
migrated_from: services.migrated_from,
|
||||||
migrated_id: services.migrated_id,
|
migrated_id: services.migrated_id,
|
||||||
created_at: services.created_at,
|
created_at: services.created_at,
|
||||||
tag_name: tags.name,
|
tag_name: tags.name,
|
||||||
|
tag_type: tags.entity_type,
|
||||||
})
|
})
|
||||||
.from(services)
|
.from(services)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
tags,
|
tags,
|
||||||
and(eq(tags.entity_type, "services"), eq(tags.entity_id, services.id))
|
and(
|
||||||
|
eq(tags.entity_id, services.id),
|
||||||
|
inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.orderBy(asc(services.name), asc(tags.name));
|
.orderBy(asc(services.name), asc(tags.name));
|
||||||
|
|
||||||
const serviceMap = new Map<string, ServiceWithTags>();
|
const serviceMap = new Map<string, ServiceWithTags>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const { tag_name, ...serviceFields } = row;
|
const { tag_name, tag_type, ...serviceFields } = row;
|
||||||
if (!serviceMap.has(row.id)) {
|
if (!serviceMap.has(row.id)) {
|
||||||
serviceMap.set(row.id, { ...serviceFields, tags: [] });
|
serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] });
|
||||||
}
|
}
|
||||||
if (tag_name) {
|
if (tag_name) {
|
||||||
serviceMap.get(row.id)!.tags.push(tag_name);
|
const target = serviceMap.get(row.id)!;
|
||||||
|
if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name);
|
||||||
|
else target.tags.push(tag_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return Array.from(serviceMap.values());
|
return Array.from(serviceMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Shared option pools for the catalog select fields (Notion-style dropdowns) ─
|
||||||
|
// Each pool is the set of distinct values currently in use, sorted alphabetically.
|
||||||
|
|
||||||
|
export type CatalogFieldOptions = {
|
||||||
|
tag: string[];
|
||||||
|
pacchetto: string[];
|
||||||
|
categoria: string[];
|
||||||
|
fase: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
|
||||||
|
const tagRows = await db
|
||||||
|
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
||||||
|
.from(tags)
|
||||||
|
.where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]));
|
||||||
|
|
||||||
|
// Distinct category / fase values (single-select pools); nulls filtered below.
|
||||||
|
const catRows = await db
|
||||||
|
.selectDistinct({ value: services.category })
|
||||||
|
.from(services);
|
||||||
|
const faseRows = await db
|
||||||
|
.selectDistinct({ value: services.fase })
|
||||||
|
.from(services);
|
||||||
|
|
||||||
|
const sortUnique = (arr: (string | null)[]) =>
|
||||||
|
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
|
||||||
|
(a, b) => a.localeCompare(b, "it")
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)),
|
||||||
|
pacchetto: sortUnique(
|
||||||
|
tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name)
|
||||||
|
),
|
||||||
|
categoria: sortUnique(catRows.map((r) => r.value)),
|
||||||
|
fase: sortUnique(faseRows.map((r) => r.value)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────
|
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────
|
||||||
|
|
||||||
export type ProjectWithPayments = {
|
export type ProjectWithPayments = {
|
||||||
|
|||||||
Reference in New Issue
Block a user