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

1329 lines
41 KiB
Svelte
Raw Normal View History

<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>