Files
descrybe/apps/web/src/routes/attributes/+page.svelte
T

1329 lines
41 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { i18n } from "$lib/i18n";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { api, ApiError, failureMessage } from "$lib/api";
import { unwrapList, unwrapTotal } from "$lib/list";
import { isCompanyAdmin } from "$lib/company-admin";
import { authSession } from "$lib/auth-session.svelte";
import { notifyApiError, notifyError } from "$lib/notify";
import type { Attribute, Category, ListResponse, MeResponse } from "$lib/types";
import PageShell from "$lib/components/PageShell.svelte";
import Alert from "$lib/components/Alert.svelte";
import Spinner from "$lib/components/Spinner.svelte";
import DataCard from "$lib/components/DataCard.svelte";
import {
Badge,
Button,
Dialog,
DropdownMenu,
DropdownMenuItem,
Input,
Label,
Select,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tabs,
TabsContent,
TabsList,
TabsTrigger
} from "$lib/components/ui";
import {
ArrowUpDown,
ChevronRight,
Database,
Download,
FileText,
List,
LoaderCircle,
MoreVertical,
Plus,
Search,
Upload,
X
} from "@lucide/svelte";
import {
attributeValueTypes,
attributeTypeLabel,
isChoiceAttributeType
} from "$lib/components/attributes/value-types";
type Attr = Attribute & {
attribute_key?: string;
value_type?: string;
parent_key?: string | null;
unit?: string | null;
example?: string | null;
updated_at?: string | null;
created_at?: string | null;
};
type Cat = Category & { unique_id?: string; name?: string | null };
type LinkedAttr = {
attribute_id: string;
name?: string;
attribute_key?: string;
required?: boolean;
};
const VALUE_TYPES = $derived(attributeValueTypes());
let attributes = $state<Attr[]>([]);
let categories = $state<Cat[]>([]);
let total = $state(0);
let error = $state("");
let success = $state("");
let loading = $state(true);
let saving = $state(false);
let canAdmin = $state(false);
let searchInput = $state("");
let searchQuery = $state("");
let categoryFilter = $state("");
let offset = $state(0);
const limit = 50;
let sortColumn = $state<"name" | "attribute_key" | "updated_at" | null>(null);
let sortDirection = $state<"asc" | "desc">("asc");
let expanded = $state<Set<string>>(new Set());
let loadedChildren = $state<Record<string, Attr[]>>({});
let loadingChildren = $state<Set<string>>(new Set());
let categoryNamesByAttr = $state<Record<string, string[]>>({});
let showAdd = $state(false);
let addTab = $state("single");
let addKey = $state("");
let addName = $state("");
let addType = $state("string");
let addUnit = $state("");
let addExample = $state("");
let addCategoryId = $state("");
let addFile = $state<File | null>(null);
let uploadError = $state("");
let editing = $state<Attr | null>(null);
let editKey = $state("");
let editName = $state("");
let editUnit = $state("");
let editExample = $state("");
let deleting = $state<Attr | null>(null);
let manageValuesAttr = $state<Attr | null>(null);
let listValues = $state<Attr[]>([]);
let valueKey = $state("");
let valueName = $state("");
let editingValue = $state<Attr | null>(null);
let showBulkAssign = $state(false);
let bulkFile = $state<File | null>(null);
let bulkStatus = $state<"idle" | "uploading" | "success" | "error">("idle");
let bulkError = $state("");
function attrKey(a: Attr): string {
return String(a.attribute_key ?? a.key ?? "");
}
function isListType(a: Attr): boolean {
return isChoiceAttributeType(a.value_type);
}
function formatRelative(iso: string | null | undefined): string {
if (!iso) return i18n.t("status.emDash");
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return i18n.t("status.emDash");
const seconds = Math.round((Date.now() - date.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat(i18n.locale, { numeric: "auto" });
const divisions: [Intl.RelativeTimeFormatUnit, number][] = [
["year", 31536000],
["month", 2592000],
["week", 604800],
["day", 86400],
["hour", 3600],
["minute", 60],
["second", 1]
];
for (const [unit, size] of divisions) {
if (Math.abs(seconds) >= size || unit === "second") {
return rtf.format(-Math.round(seconds / size), unit);
}
}
return i18n.t("status.emDash");
}
function downloadText(filename: string, content: string, mime: string) {
const blob = new Blob([content], { type: mime });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
function downloadAttributesTemplate(tsv = false) {
const headers = ["attribute_key", "name", "value_type", "category_id", "unit", "example", "parent_key"];
const examples = [
["width", "Width", "number", "T-SHIRTS", "cm", "15.6", ""],
["warranty", "Warranty included", "boolean", "T-SHIRTS", "", "", ""],
["release_date", "Release date", "date", "T-SHIRTS", "", "", ""],
["color", i18n.t("attributes.color"), "list", "T-SHIRTS", "", "", ""],
["color-red", "Red", "string", "T-SHIRTS", "", "", "color"],
["features", i18n.t("catalog.features"), "multiselect", "T-SHIRTS", "", "", ""],
["feature-waterproof", "Waterproof", "string", "T-SHIRTS", "", "", "features"]
];
const sep = tsv ? "\t" : ",";
const body = [headers.join(sep), ...examples.map((row) => row.join(sep))].join("\n");
downloadText(
tsv ? "attributes_template.tsv" : "attributes_template.csv",
body,
tsv ? "text/tab-separated-values" : "text/csv"
);
}
function downloadAssignTemplate() {
const headers = ["category_id", "attribute_key"];
const examples = [
["category-123", "width"],
["category-123", "color"],
["category-456", "width"]
];
const csv = [headers.join(","), ...examples.map((r) => r.join(","))].join("\n");
downloadText("category_attribute_assignments_template.csv", csv, "text/csv");
}
async function loadCategories() {
try {
const payload = await api<ListResponse<Cat>>("/api/categories?limit=500");
categories = unwrapList(payload);
} catch {
categories = [];
}
}
async function loadCategoryNames(items: Attr[]) {
const map: Record<string, string[]> = {};
const catsToProbe = categoryFilter
? categories.filter((c) => String(c.unique_id ?? c.id) === categoryFilter)
: categories.slice(0, 40);
await Promise.all(
catsToProbe.map(async (cat) => {
try {
const res = await api<{ category_attributes?: LinkedAttr[] }>(
`/api/categories/${cat.id}/attributes`
);
const links = res.category_attributes ?? [];
const catLabel = String(cat.name ?? cat.unique_id ?? cat.id);
for (const link of links) {
const id = String(link.attribute_id);
if (!map[id]) map[id] = [];
if (!map[id].includes(catLabel)) map[id].push(catLabel);
}
} catch {
/* ignore */
}
})
);
for (const a of items) {
const id = String(a.id);
if (!map[id]) map[id] = categoryNamesByAttr[id] ?? [];
}
categoryNamesByAttr = map;
}
async function load() {
loading = true;
error = "";
try {
if (categoryFilter) {
const cat = categories.find((c) => String(c.unique_id ?? c.id) === categoryFilter);
if (!cat) {
attributes = [];
total = 0;
return;
}
const links = await api<{ category_attributes?: LinkedAttr[] }>(
`/api/categories/${cat.id}/attributes`
);
const linked = links.category_attributes ?? [];
const ids = new Set(linked.map((l) => String(l.attribute_id)));
const payload = await api<ListResponse<Attr> & { total?: number }>(
`/api/attributes?limit=2000&offset=0&roots=1${searchQuery ? `&q=${encodeURIComponent(searchQuery)}` : ""}`
);
let items = unwrapList(payload).filter((a) => ids.has(String(a.id)) && !a.parent_key);
if (searchQuery.trim()) {
const q = searchQuery.trim().toLowerCase();
items = items.filter(
(a) =>
String(a.name ?? "")
.toLowerCase()
.includes(q) || attrKey(a).toLowerCase().includes(q)
);
}
attributes = sortItems(items);
total = attributes.length;
await loadCategoryNames(attributes);
return;
}
const params = new URLSearchParams({ limit: String(limit), offset: String(offset), roots: "1" });
if (searchQuery.trim()) params.set("q", searchQuery.trim());
const payload = await api<ListResponse<Attr> & { total?: number }>(`/api/attributes?${params}`);
const items = unwrapList(payload);
attributes = sortItems(items);
total = unwrapTotal(payload) ?? attributes.length;
await loadCategoryNames(attributes);
} catch (err) {
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("attributes.loadFailed"));
} finally {
loading = false;
}
}
function sortItems(items: Attr[]): Attr[] {
if (!sortColumn) return items;
const dir = sortDirection === "asc" ? 1 : -1;
return [...items].sort((a, b) => {
let av = "";
let bv = "";
if (sortColumn === "name") {
av = String(a.name ?? "");
bv = String(b.name ?? "");
} else if (sortColumn === "attribute_key") {
av = attrKey(a);
bv = attrKey(b);
} else {
av = String(a.updated_at ?? "");
bv = String(b.updated_at ?? "");
}
return av.localeCompare(bv) * dir;
});
}
function toggleSort(column: "name" | "attribute_key" | "updated_at") {
if (sortColumn === column) {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
} else {
sortColumn = column;
sortDirection = "asc";
}
attributes = sortItems(attributes);
}
onMount(() => {
void (async () => {
try {
const me = await api<MeResponse>("/api/auth/me");
authSession.setMe(me);
canAdmin = isCompanyAdmin(me);
} catch {
canAdmin = authSession.isCompanyAdmin;
}
await loadCategories();
await load();
})();
});
const rootAttributes = $derived(attributes.filter((a) => !a.parent_key));
const pageLabel = $derived(
total === 0
? i18n.t("attributes.zero")
: `Showing ${offset + 1}${Math.min(offset + rootAttributes.length, total)} of ${total} attributes`
);
async function loadChildAttributes(parent: Attr) {
const key = attrKey(parent);
if (!key || loadedChildren[key] || loadingChildren.has(key)) return;
loadingChildren = new Set(loadingChildren).add(key);
try {
const fromPage = attributes.filter((a) => a.parent_key === key);
const payload = await api<ListResponse<Attr>>(
`/api/attributes?limit=500&parent_key=${encodeURIComponent(key)}`
);
const fetched = unwrapList(payload);
const byId = new Map<string, Attr>();
for (const child of [...fromPage, ...fetched]) byId.set(String(child.id), child);
loadedChildren = { ...loadedChildren, [key]: [...byId.values()] };
} catch {
loadedChildren = { ...loadedChildren, [key]: [] };
} finally {
const next = new Set(loadingChildren);
next.delete(key);
loadingChildren = next;
}
}
function toggleExpand(attr: Attr) {
const key = attrKey(attr);
const next = new Set(expanded);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
if (isListType(attr)) void loadChildAttributes(attr);
}
expanded = next;
}
function resetAddForm() {
addKey = "";
addName = "";
addType = "string";
addUnit = "";
addExample = "";
addCategoryId = "";
addFile = null;
uploadError = "";
addTab = "single";
}
function openAdd() {
resetAddForm();
error = "";
success = "";
showAdd = true;
}
async function createAttribute(event: Event) {
event.preventDefault();
saving = true;
error = "";
success = "";
try {
const created = await api<Attr>("/api/attributes", {
method: "POST",
body: {
attribute_key: addKey,
name: addName,
value_type: addType,
unit: addUnit || null,
example: addExample || null,
parent_key: null
}
});
let linkFailed = "";
if (addCategoryId) {
const cat = categories.find((c) => String(c.unique_id ?? c.id) === addCategoryId);
if (cat) {
try {
await api(`/api/categories/${cat.id}/attributes`, {
method: "POST",
body: { attribute_id: String(created.id), required: false }
});
} catch (linkErr) {
linkFailed = failureMessage(
linkErr,
i18n.t("attributes.assignFailed")
);
}
}
}
const createdKey = String(created.attribute_key ?? addKey).trim();
showAdd = false;
resetAddForm();
if (linkFailed) {
success = i18n.t("flash.attrs.createdLinkFailed");
error = linkFailed;
notifyError(linkFailed);
} else {
success = i18n.t("flash.attrs.created");
}
offset = 0;
categoryFilter = "";
// Surface the new row immediately — default list is paginated/sorted and often hides it.
searchInput = createdKey;
searchQuery = createdKey;
await load();
} catch (err) {
error = notifyApiError(err, i18n.t("toast.attrs.createFailed"));
} finally {
saving = false;
}
}
async function bulkImport(event: Event) {
event.preventDefault();
if (!addFile) return;
saving = true;
uploadError = "";
error = "";
success = "";
try {
const form = new FormData();
form.append("file", addFile);
await api("/api/attributes/import", { method: "POST", body: form });
showAdd = false;
resetAddForm();
success = i18n.t("flash.attrs.csvImported");
offset = 0;
await load();
} catch (err) {
uploadError = notifyApiError(err, i18n.t("toast.attrs.importFailed"));
} finally {
saving = false;
}
}
function openEdit(attr: Attr) {
editing = attr;
editKey = attrKey(attr);
editName = String(attr.name ?? "");
editUnit = String(attr.unit ?? "");
editExample = String(attr.example ?? "");
error = "";
success = "";
}
async function saveEdit(event: Event) {
event.preventDefault();
if (!editing) return;
saving = true;
error = "";
success = "";
try {
await api(`/api/attributes/${editing.id}`, {
method: "PATCH",
body: {
name: editName,
value_type: editing.value_type,
unit: editUnit.trim() || null,
example: editExample.trim() || null
}
});
editing = null;
success = i18n.t("flash.attrs.updated");
await load();
} catch (err) {
error = notifyApiError(err, i18n.t("toast.attrs.updateFailed"));
} finally {
saving = false;
}
}
async function confirmDelete() {
if (!deleting) return;
saving = true;
error = "";
success = "";
try {
await api(`/api/attributes/${deleting.id}`, { method: "DELETE" });
deleting = null;
success = i18n.t("flash.attrs.deleted");
await load();
} catch (err) {
error = notifyApiError(err, i18n.t("toast.attrs.deleteFailed"));
} finally {
saving = false;
}
}
async function openManageValues(attr: Attr) {
manageValuesAttr = attr;
editingValue = null;
valueKey = "";
valueName = "";
await loadChildAttributes(attr);
listValues = loadedChildren[attrKey(attr)] ?? [];
}
async function refreshListValues() {
if (!manageValuesAttr) return;
const key = attrKey(manageValuesAttr);
loadedChildren = { ...loadedChildren };
delete loadedChildren[key];
await loadChildAttributes(manageValuesAttr);
listValues = loadedChildren[key] ?? [];
}
async function saveListValue(event: Event) {
event.preventDefault();
if (!manageValuesAttr) return;
saving = true;
error = "";
try {
const wasEdit = !!editingValue;
if (editingValue) {
await api(`/api/attributes/${editingValue.id}`, {
method: "PATCH",
body: { name: valueName, value_type: "string" }
});
} else {
await api("/api/attributes", {
method: "POST",
body: {
attribute_key: valueKey,
name: valueName,
value_type: "string",
parent_key: attrKey(manageValuesAttr)
}
});
}
valueKey = "";
valueName = "";
editingValue = null;
await refreshListValues();
success = wasEdit ? i18n.t("attributes.valueUpdated") : i18n.t("attributes.valueAdded");
} catch (err) {
error = failureMessage(err, i18n.t("attributes.saveValueFailed"));
} finally {
saving = false;
}
}
function startEditValue(value: Attr) {
editingValue = value;
valueKey = attrKey(value);
valueName = String(value.name ?? "");
}
function cancelEditValue() {
editingValue = null;
valueKey = "";
valueName = "";
}
async function deleteListValue(id: string | number) {
saving = true;
error = "";
try {
await api(`/api/attributes/${id}`, { method: "DELETE" });
await refreshListValues();
success = i18n.t("flash.attrs.valueDeleted");
} catch (err) {
error = failureMessage(err, i18n.t("attributes.deleteValueFailed"));
} finally {
saving = false;
}
}
async function submitBulkAssign(event: Event) {
event.preventDefault();
if (!bulkFile) return;
bulkStatus = "uploading";
bulkError = "";
error = "";
success = "";
try {
const text = await bulkFile.text();
const lines = text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (lines.length < 2) throw new Error(i18n.t("attributes.csvNeedHeader"));
const header = lines[0].split(",").map((h) => h.trim().toLowerCase());
const catIdx = header.indexOf("category_id");
const keyIdx = header.indexOf("attribute_key");
if (catIdx < 0 || keyIdx < 0) throw new Error(i18n.t("attributes.csvNeedColumns"));
const allPayload = await api<ListResponse<Attr>>("/api/attributes?limit=2000");
const allAttrs = unwrapList(allPayload);
const byKey = new Map(allAttrs.map((a) => [attrKey(a), a]));
const byUnique = new Map(categories.map((c) => [String(c.unique_id ?? ""), c]));
let linked = 0;
const failures: string[] = [];
for (let i = 1; i < lines.length; i++) {
const cols = lines[i].split(",").map((c) => c.trim());
const catUid = cols[catIdx];
const aKey = cols[keyIdx];
const cat = byUnique.get(catUid);
const attr = byKey.get(aKey);
if (!cat || !attr) {
failures.push(i18n.t("attributes.rowUnknown", { row: i + 1 }));
continue;
}
try {
await api(`/api/categories/${cat.id}/attributes`, {
method: "POST",
body: { attribute_id: String(attr.id), required: false }
});
linked += 1;
} catch (err) {
failures.push(i18n.t("attributes.rowLinkFailed", { row: i + 1, detail: failureMessage(err, i18n.t("attributes.linkFailedShort")) }));
}
}
if (failures.length && linked === 0) {
bulkStatus = "error";
bulkError = failures.slice(0, 5).join("\n");
return;
}
bulkStatus = "success";
success = i18n.t("flash.attrs.assignedLinks", { count: linked });
if (failures.length) success += ` ${i18n.t("attributes.rowsFailed", { count: failures.length })}`;
await load();
} catch (err) {
bulkStatus = "error";
bulkError = failureMessage(err, i18n.t("attributes.bulkAssignFailed"));
}
}
function applySearch() {
searchQuery = searchInput;
offset = 0;
void load();
}
function clearSearch() {
searchInput = "";
searchQuery = "";
offset = 0;
void load();
}
function onCategoryFilterChange() {
offset = 0;
void load();
}
function categoryBadges(attr: Attr): string[] {
return categoryNamesByAttr[String(attr.id)] ?? [];
}
</script>
<PageShell title={i18n.t("attributes.title")} description={i18n.t("attributes.description")}>
{#snippet actions()}
<Button variant="outline" onclick={() => ((showBulkAssign = true), (bulkStatus = "idle"), (bulkFile = null), (bulkError = ""))}>
<Database class="h-4 w-4" />
{i18n.t("attributes.assignToCategories")}
</Button>
<Button onclick={openAdd} data-assistant-target="attributes-add" data-tour="attributes-add">
<Plus class="h-4 w-4" />
{i18n.t("attributes.addAttribute")}
</Button>
{/snippet}
<Alert message={error} />
<Alert tone="success" message={success} />
<DataCard>
{#snippet toolbar()}
<div class="flex flex-wrap items-center gap-4">
<div class="relative min-w-0 w-full max-w-[350px] flex-1">
<Search class="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<form
onsubmit={(e) => {
e.preventDefault();
applySearch();
}}
>
<Input
class="pl-9 pr-9"
placeholder={i18n.t("attributes.searchPlaceholder")}
bind:value={searchInput}
aria-label={i18n.t("attributes.searchAria")}
/>
{#if searchInput}
<button
type="button"
class="absolute right-1 top-1/2 flex h-7 w-7 -translate-y-1/2 items-center justify-center rounded-md text-muted-foreground hover:bg-accent"
aria-label={i18n.t("catalog.clearSearch")}
onclick={clearSearch}
>
<X class="h-4 w-4" />
</button>
{/if}
</form>
</div>
<div class="w-full max-w-[350px]">
<Select
aria-label={i18n.t("attributes.filterByCategory")}
bind:value={categoryFilter}
onchange={onCategoryFilterChange}
>
<option value="">{i18n.t("attributes.allCategories")}</option>
{#each categories as cat}
<option value={String(cat.unique_id ?? cat.id)}>
{cat.name ?? cat.unique_id}{#if cat.unique_id}
({cat.unique_id}){/if}
</option>
{/each}
</Select>
</div>
</div>
{/snippet}
{#if loading}
<div class="p-8"><Spinner label={i18n.t("attributes.loading")} /></div>
{:else}
<Table>
<TableHeader>
<TableRow>
<TableHead>
<button
type="button"
class="inline-flex h-auto items-center p-0 font-semibold hover:underline"
onclick={() => toggleSort("name")}
>
{i18n.t("attributes.col.name")}
<ArrowUpDown class="ml-2 h-4 w-4 {sortColumn === "name" ? "text-primary" : ""}" />
</button>
</TableHead>
<TableHead class="max-w-[12rem]">
<button
type="button"
class="inline-flex h-auto items-center p-0 font-semibold hover:underline"
onclick={() => toggleSort("attribute_key")}
>
{i18n.t("attributes.col.key")}
<ArrowUpDown
class="ml-2 h-4 w-4 {sortColumn === "attribute_key" ? "text-primary" : ""}"
/>
</button>
</TableHead>
<TableHead class="whitespace-nowrap">{i18n.t("attributes.col.type")}</TableHead>
<TableHead class="whitespace-nowrap">{i18n.t("attributes.col.unit")}</TableHead>
<TableHead>{i18n.t("attributes.col.categories")}</TableHead>
<TableHead class="whitespace-nowrap">
<button
type="button"
class="inline-flex h-auto items-center p-0 font-semibold hover:underline"
onclick={() => toggleSort("updated_at")}
>
{i18n.t("attributes.col.updated")}
<ArrowUpDown
class="ml-2 h-4 w-4 {sortColumn === "updated_at" ? "text-primary" : ""}"
/>
</button>
</TableHead>
<TableHead stickyRight>{i18n.t("attributes.col.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#if rootAttributes.length === 0}
<TableRow>
<TableCell colspan={7} class="py-8 text-center text-muted-foreground">
<div class="flex flex-col items-center gap-3">
<FileText class="h-8 w-8 text-muted-foreground/50" />
<p>{i18n.t("attributes.emptyTitle")}</p>
<p class="text-sm text-muted-foreground">
{#if searchQuery.trim() || categoryFilter}
{i18n.t("attributes.emptyFiltered")}
{:else}
{i18n.t("attributes.emptyHint")}
{/if}
</p>
<div class="flex flex-wrap justify-center gap-2 pt-1">
{#if searchQuery.trim() || categoryFilter}
<Button
variant="outline"
onclick={() => {
categoryFilter = "";
clearSearch();
}}
>
{i18n.t("catalog.clearFilters")}
</Button>
{:else}
<Button onclick={openAdd} data-tour="attributes-empty-add">
<Plus class="mr-2 h-4 w-4" />
{i18n.t("attributes.addAttribute")}
</Button>
{/if}
</div>
</div>
</TableCell>
</TableRow>
{:else}
{#each rootAttributes as attribute (attribute.id)}
{@const key = attrKey(attribute)}
{@const hasChildren = isListType(attribute)}
{@const isExpanded = expanded.has(key)}
{@const children = loadedChildren[key] ?? []}
{@const isLoadingKids = loadingChildren.has(key)}
{@const hasLoaded = key in loadedChildren}
{@const cats = categoryBadges(attribute)}
<TableRow>
<TableCell>
<div class="flex items-center gap-2">
{#if hasChildren}
<Button
variant="ghost"
size="icon"
class="h-5 w-5"
aria-label={isExpanded ? i18n.t("catalog.collapse") : i18n.t("catalog.expand")}
aria-expanded={isExpanded}
onclick={() => toggleExpand(attribute)}
>
<ChevronRight
class="h-4 w-4 transition-transform {isExpanded ? "rotate-90" : ""}"
/>
</Button>
{/if}
<span>{attribute.name}</span>
{#if hasChildren && hasLoaded && children.length === 0}
<Badge variant="outline" class="ml-2 text-xs">{i18n.t("attributes.noValues")}</Badge>
{/if}
</div>
</TableCell>
<TableCell class="max-w-[12rem] truncate font-medium" title={key || undefined}>
{key || i18n.t("status.emDash")}
</TableCell>
<TableCell class="whitespace-nowrap">
<Badge variant="secondary" title={attribute.value_type ?? undefined}>
{attributeTypeLabel(attribute.value_type)}
</Badge>
</TableCell>
<TableCell class="whitespace-nowrap">{attribute.unit ?? i18n.t("status.emDash")}</TableCell>
<TableCell>
{#if cats.length === 0}
<span class="text-muted-foreground">{i18n.t("status.emDash")}</span>
{:else if cats.length <= 2}
<div class="flex flex-wrap gap-1">
{#each cats as name}
<Badge variant="outline" class="text-xs">{name}</Badge>
{/each}
</div>
{:else}
<div class="flex items-center gap-1" title={cats.join(", ")}>
<Badge variant="outline" class="text-xs">{cats[0]}</Badge>
<Badge variant="secondary" class="text-xs">+{cats.length - 1}</Badge>
</div>
{/if}
</TableCell>
<TableCell class="whitespace-nowrap">{formatRelative(attribute.updated_at)}</TableCell>
<TableCell stickyRight>
<DropdownMenu class="w-44">
{#snippet trigger({ open, toggle })}
<Button
variant="ghost"
size="icon"
aria-label={i18n.t("attributes.actions")}
aria-haspopup="menu"
aria-expanded={open}
onclick={(e) => {
e.stopPropagation();
toggle();
}}
>
<MoreVertical class="h-4 w-4" />
</Button>
{/snippet}
<DropdownMenuItem onclick={() => openEdit(attribute)}>{i18n.t("attributes.editDetails")}</DropdownMenuItem>
{#if hasChildren}
<DropdownMenuItem onclick={() => void openManageValues(attribute)}>
{i18n.t("attributes.manageValues")}
</DropdownMenuItem>
{/if}
{#if canAdmin}
<DropdownMenuItem
class="text-destructive focus:text-destructive"
onclick={() => {
deleting = attribute;
}}
>
{i18n.t("common.delete")}
</DropdownMenuItem>
{/if}
</DropdownMenu>
</TableCell>
</TableRow>
{#if isExpanded && hasChildren}
{#if isLoadingKids}
<TableRow class="bg-muted/30">
<TableCell colspan={7}>
<div class="flex items-center pl-6 text-muted-foreground">
<LoaderCircle class="mr-2 h-4 w-4 animate-spin" />
{i18n.t("attributes.loadingValues")}
</div>
</TableCell>
</TableRow>
{:else if children.length > 0}
{#each children as child (child.id)}
<TableRow class="bg-muted/30">
<TableCell colspan={2}>
<div class="flex items-center pl-6">
<span class="mr-2 inline-block h-2 w-2 rounded-full bg-muted-foreground"
></span>
{child.name}
</div>
</TableCell>
<TableCell colspan={5}>{attrKey(child)}</TableCell>
</TableRow>
{/each}
{:else}
<TableRow class="bg-muted/30">
<TableCell colspan={7}>
<div class="flex items-center pl-6 text-muted-foreground">
<List class="mr-2 h-4 w-4" />
{i18n.t("attributes.noValuesYetHint")}
</div>
</TableCell>
</TableRow>
{/if}
{/if}
{/each}
{/if}
</TableBody>
</Table>
{/if}
{#snippet footer()}
{#if !loading && rootAttributes.length > 0 && !categoryFilter}
<div class="flex items-center justify-between gap-2 border-t border-border px-4 py-4">
<span class="text-sm text-muted-foreground">{pageLabel}</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={offset === 0}
onclick={() => {
offset = Math.max(0, offset - limit);
void load();
}}>{i18n.t("common.previous")}</Button
>
<Button
variant="outline"
size="sm"
disabled={offset + limit >= total}
onclick={() => {
offset = offset + limit;
void load();
}}>{i18n.t("common.next")}</Button
>
</div>
</div>
{/if}
{/snippet}
</DataCard>
</PageShell>
<Dialog
bind:open={showAdd}
title={i18n.t("attributes.addNew")}
description={i18n.t("attributes.createDescription")}
class="max-w-[600px]"
onClose={resetAddForm}
>
<Tabs bind:value={addTab} class="w-full">
{#if canAdmin}
<TabsList class="grid w-full grid-cols-2">
<TabsTrigger value="single">{i18n.t("attributes.tab.single")}</TabsTrigger>
<TabsTrigger value="bulk">{i18n.t("attributes.tab.bulk")}</TabsTrigger>
</TabsList>
{/if}
<TabsContent value="single" class="mt-4">
<form class="space-y-4" onsubmit={createAttribute}>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="add-key">{i18n.t("attributes.field.key")}</Label>
<Input id="add-key" placeholder={i18n.t("attributes.field.keyPlaceholder")} bind:value={addKey} required />
</div>
<div class="grid gap-2">
<Label for="add-name">{i18n.t("attributes.field.displayName")}</Label>
<Input id="add-name" placeholder={i18n.t("attributes.displayName")} bind:value={addName} required />
</div>
<div class="grid gap-2">
<Label for="add-type">{i18n.t("attributes.field.type")}</Label>
<Select id="add-type" bind:value={addType}>
{#each VALUE_TYPES as t}
<option value={t.value}>{t.label}</option>
{/each}
</Select>
<p class="text-sm text-muted-foreground">
{VALUE_TYPES.find((t) => t.value === addType)?.hint ??
i18n.t("attributes.valueTypeHint")}
</p>
</div>
<div class="grid gap-2">
<Label for="add-cat">{i18n.t("attributes.field.category")}</Label>
<Select id="add-cat" bind:value={addCategoryId}>
<option value="">{i18n.t("attributes.field.categoryPlaceholder")}</option>
{#each categories as cat}
<option value={String(cat.unique_id ?? cat.id)}>
{cat.name ?? cat.unique_id}{#if cat.unique_id}
({cat.unique_id}){/if}
</option>
{/each}
</Select>
<p class="text-sm text-muted-foreground">
{i18n.t("attributes.field.categoryHint")}
</p>
</div>
{#if !isChoiceAttributeType(addType)}
<div class="grid gap-2">
<Label for="add-unit">{i18n.t("attributes.field.unitOptional")}</Label>
<Input id="add-unit" placeholder={i18n.t("attributes.field.unitPlaceholder")} bind:value={addUnit} />
</div>
<div class="grid gap-2">
<Label for="add-example">{i18n.t("attributes.field.exampleOptional")}</Label>
<Input id="add-example" placeholder={i18n.t("attributes.field.examplePlaceholder")} bind:value={addExample} />
</div>
{/if}
</div>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onclick={() => (showAdd = false)}>{i18n.t("common.cancel")}</Button>
<Button type="submit" loading={saving}>{saving ? i18n.t("catalog.adding") : i18n.t("attributes.create")}</Button>
</div>
</form>
</TabsContent>
{#if canAdmin}
<TabsContent value="bulk" class="mt-4">
<form class="space-y-4" onsubmit={bulkImport}>
<div class="grid gap-2">
<Label for="bulk-file">{i18n.t("attributes.bulk.fileLabel")}</Label>
<div class="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onclick={() => downloadAttributesTemplate(false)}>
<Download class="h-4 w-4" />
{i18n.t("attributes.bulk.downloadCsv")}
</Button>
<Button type="button" variant="outline" size="sm" onclick={() => downloadAttributesTemplate(true)}>
<Download class="h-4 w-4" />
{i18n.t("attributes.bulk.downloadTsv")}
</Button>
</div>
<div class="flex items-center gap-4">
<Input
id="bulk-file"
type="file"
accept=".csv,.tsv,text/csv,text/tab-separated-values"
onchange={(e) => {
const input = e.currentTarget as HTMLInputElement;
addFile = input.files?.[0] ?? null;
uploadError = "";
}}
required
/>
<Button type="submit" disabled={!addFile} loading={saving}>
<Upload class="h-4 w-4" />
{saving ? i18n.t("common.processing") : i18n.t("common.upload")}
</Button>
</div>
{#if uploadError}
<div class="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive whitespace-pre-wrap">
{uploadError}
</div>
{/if}
<div class="space-y-2 text-sm text-muted-foreground">
<p>{i18n.t("attributes.bulk.uploadHint")}</p>
<ul class="list-inside list-disc space-y-1">
<li><span class="font-mono text-xs">attribute_key</span>: {i18n.t("attributes.bulk.col.key")}</li>
<li><span class="font-mono text-xs">name</span>: {i18n.t("attributes.bulk.col.name")}</li>
<li>
<span class="font-mono text-xs">value_type</span>: {i18n.t("attributes.bulk.col.valueType")}
</li>
<li><span class="font-mono text-xs">category_id</span>: {i18n.t("attributes.bulk.col.categoryId")}</li>
<li><span class="font-mono text-xs">unit</span> / <span class="font-mono text-xs">example</span>: {i18n.t("attributes.bulk.col.unitExample")}</li>
<li><span class="font-mono text-xs">parent_key</span>: {i18n.t("attributes.bulk.col.parentKey")}</li>
</ul>
</div>
</div>
</form>
</TabsContent>
{/if}
</Tabs>
</Dialog>
<Dialog
open={!!editing}
title={i18n.t("attributes.edit")}
description={editing ? i18n.t("attributes.editDesc", { name: String(editing.name ?? "") }) : ""}
class="max-w-[600px]"
onClose={() => (editing = null)}
>
{#if editing}
<form class="space-y-6 py-2" onsubmit={saveEdit}>
<div class="grid gap-4">
<div class="grid gap-2">
<Label for="edit-key">{i18n.t("attributes.field.key")}</Label>
<Input id="edit-key" bind:value={editKey} required disabled />
<p class="text-sm text-muted-foreground">{i18n.t("attributes.field.keyHint")}</p>
</div>
<div class="grid gap-2">
<Label for="edit-name">{i18n.t("attributes.field.displayName")}</Label>
<Input id="edit-name" bind:value={editName} required />
<p class="text-sm text-muted-foreground">{i18n.t("attributes.field.displayNameHint")}</p>
</div>
{#if !isListType(editing)}
<div class="grid gap-2">
<Label for="edit-unit">{i18n.t("attributes.field.unitOptional")}</Label>
<Input id="edit-unit" placeholder={i18n.t("attributes.field.unitPlaceholder")} bind:value={editUnit} />
</div>
<div class="grid gap-2">
<Label for="edit-example">{i18n.t("attributes.field.exampleOptional")}</Label>
<Input id="edit-example" placeholder={i18n.t("attributes.field.examplePlaceholder")} bind:value={editExample} />
</div>
{/if}
</div>
{#if error}
<p class="text-sm text-destructive" role="alert">{error}</p>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (editing = null)}>{i18n.t("common.cancel")}</Button>
<Button type="submit" loading={saving}>{saving ? i18n.t("catalog.saving") : i18n.t("catalog.saveChanges")}</Button>
</div>
</form>
{/if}
</Dialog>
<Dialog
open={!!deleting}
title={i18n.t("attributes.delete")}
description={deleting
? i18n.t("attributes.deleteDesc", { name: String(deleting.name ?? "") })
: ""}
class="max-w-[600px]"
onClose={() => (deleting = null)}
>
{#if deleting && isListType(deleting)}
<p class="mb-4 flex items-center gap-2 text-sm text-destructive">
{i18n.t("attributes.deleteWarningValues")}
</p>
{/if}
{#snippet footer()}
<Button variant="outline" onclick={() => (deleting = null)}>{i18n.t("common.cancel")}</Button>
<Button variant="destructive" loading={saving} onclick={() => void confirmDelete()}>
{saving ? i18n.t("catalog.deleting") : i18n.t("catalog.delete")}
</Button>
{/snippet}
</Dialog>
<Dialog
open={!!manageValuesAttr}
title={manageValuesAttr ? i18n.t("attributes.manageTitleNamed", { name: String(manageValuesAttr.name ?? "") }) : i18n.t("attributes.manageTitle")}
description={i18n.t("attributes.manageValues")}
class="max-w-[800px]"
onClose={() => {
manageValuesAttr = null;
cancelEditValue();
}}
>
<form class="mb-4 space-y-4" onsubmit={saveListValue}>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div class="space-y-2">
<Label for="value-key">{i18n.t("attributes.valueKey")}</Label>
<Input
id="value-key"
placeholder={manageValuesAttr ? `${attrKey(manageValuesAttr)}-value` : "value-key"}
bind:value={valueKey}
required
disabled={!!editingValue}
/>
<p class="text-sm text-muted-foreground">{i18n.t("attributes.valueKeyHint")}</p>
</div>
<div class="space-y-2">
<Label for="value-name">{i18n.t("attributes.displayValue")}</Label>
<Input id="value-name" placeholder={i18n.t("attributes.displayValuePlaceholder")} bind:value={valueName} required />
<p class="text-sm text-muted-foreground">{i18n.t("attributes.displayValueHint")}</p>
</div>
</div>
<div class="flex justify-end gap-2">
{#if editingValue}
<Button type="button" variant="outline" onclick={cancelEditValue}>{i18n.t("common.cancel")}</Button>
{/if}
<Button type="submit" loading={saving}>
{saving ? i18n.t("catalog.saving") : editingValue ? i18n.t("attributes.updateValue") : i18n.t("attributes.addValue")}
</Button>
</div>
</form>
<div class="max-h-[400px] overflow-auto rounded-lg border border-border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{i18n.t("attributes.valueKey")}</TableHead>
<TableHead>{i18n.t("attributes.displayValue")}</TableHead>
<TableHead stickyRight>{i18n.t("attributes.col.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each listValues as value (value.id)}
<TableRow>
<TableCell class="font-mono">{attrKey(value)}</TableCell>
<TableCell>{value.name}</TableCell>
<TableCell stickyRight>
<div class="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onclick={() => startEditValue(value)}>{i18n.t("common.edit")}</Button>
{#if canAdmin}
<Button
variant="ghost"
size="sm"
class="text-destructive"
onclick={() => void deleteListValue(value.id)}>{i18n.t("common.delete")}</Button
>
{/if}
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="py-6 text-center text-muted-foreground"
>{i18n.t("attributes.noValuesYet")}</TableCell
>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
</Dialog>
<Dialog
bind:open={showBulkAssign}
title={i18n.t("attributes.assignToCategories")}
description={i18n.t("attributes.assignDesc")}
class="max-w-[600px]"
onClose={() => {
bulkFile = null;
bulkStatus = "idle";
bulkError = "";
}}
>
<form class="space-y-4" onsubmit={submitBulkAssign}>
<div class="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" onclick={downloadAssignTemplate}>
<Download class="h-4 w-4" />
{i18n.t("attributes.downloadTemplate")}
</Button>
</div>
<div class="space-y-2">
<Label for="assign-file">{i18n.t("attributes.csvFile")}</Label>
<Input
id="assign-file"
type="file"
accept=".csv,text/csv"
onchange={(e) => {
const input = e.currentTarget as HTMLInputElement;
bulkFile = input.files?.[0] ?? null;
bulkStatus = "idle";
bulkError = "";
}}
required
/>
</div>
{#if bulkError}
<div class="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive whitespace-pre-wrap">
{bulkError}
</div>
{/if}
{#if bulkStatus === "success"}
<div class="rounded-md border border-border bg-muted/40 p-3 text-sm text-foreground">
{i18n.t("attributes.assignSuccess")}
</div>
{/if}
<div class="flex justify-end gap-2">
<Button type="button" variant="outline" onclick={() => (showBulkAssign = false)}>{i18n.t("common.close")}</Button>
<Button type="submit" disabled={!bulkFile || bulkStatus === "uploading"} loading={bulkStatus === "uploading"}>
<Upload class="h-4 w-4" />
{bulkStatus === "uploading" ? i18n.t("catalog.assigning") : "Upload & Assign"}
</Button>
</div>
</form>
</Dialog>