1329 lines
41 KiB
Svelte
1329 lines
41 KiB
Svelte
<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>
|