Files
saas_frontend/src/application/reference/ReferencePage.tsx
T
2026-08-31 20:05:33 -04:00

431 lines
14 KiB
TypeScript

import React, { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, List, Lock, Plus, Trash2, Undo2 } from "lucide-react";
import {
CustomButton,
CustomConfirmationModal,
CustomInput,
CustomLoader,
CustomModal,
} from "../../components/custom";
import { referenceApi } from "./ReferenceApi";
import type { ReferenceItem, ReferenceList } from "./ReferenceApi";
/**
* Reference lists.
*
* The screen exists to make one asymmetry obvious, because it is surprising
* until it is explained: **a list the platform maintains is visible and not
* editable — but you can usually add your own items to it.** Both halves matter.
* Somebody who thinks they cannot extend a standard list will copy it, and the
* copy drifts the moment the standard one changes.
*
* So platform rows are marked, platform items are marked, and the edit controls
* are simply absent on them rather than present and failing.
*
* Retiring is offered instead of deleting, and says which: a record from last
* year still points at the item, and removing it would break the report that
* shows it months after the change that caused it.
*/
const ItemsPanel: React.FC<{ list: ReferenceList; onChanged: () => void }> = ({
list,
onChanged,
}) => {
const { t } = useTranslation(["reference", "common"]);
const [items, setItems] = useState<ReferenceItem[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [showRetired, setShowRetired] = useState(false);
const [code, setCode] = useState("");
const [label, setLabel] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async () => {
setIsLoading(true);
setFailed(false);
try {
setItems(await referenceApi.items(list.code, showRetired));
} catch {
setFailed(true);
} finally {
setIsLoading(false);
}
}, [list.code, showRetired]);
useEffect(() => {
void load();
}, [load]);
const add = async () => {
setError("");
setIsBusy(true);
try {
await referenceApi.addItem(list.code, {
code: code.trim(),
label: label.trim(),
sort_order: items.length,
});
setCode("");
setLabel("");
await load();
onChanged();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("errors.generic"));
} finally {
setIsBusy(false);
}
};
const retire = async (item: ReferenceItem) => {
await referenceApi.retireItem(item.id);
await load();
};
const restore = async (item: ReferenceItem) => {
await referenceApi.updateItem(item.id, { is_active: true });
await load();
};
const canAdd = !list.is_platform || list.allows_custom_items;
return (
<div className="space-y-4">
{list.is_platform && (
<p className="rounded-md border border-[var(--card-border)] p-3 text-sm text-[var(--text-secondary)]">
{canAdd ? t("platform.extendable") : t("platform.closed")}
</p>
)}
{isLoading ? (
<div className="flex justify-center py-6">
<CustomLoader />
</div>
) : failed ? (
<p className="py-4 text-sm text-[var(--text-secondary)]">
{t("errors.loadFailed")}
</p>
) : items.length === 0 ? (
<p className="py-4 text-sm text-[var(--text-secondary)]">
{t("items.empty")}
</p>
) : (
<ul className="divide-y divide-[var(--card-border)]">
{items.map((item) => (
<li
key={item.id}
className={`flex flex-wrap items-center gap-2 py-3 ${item.is_active ? "" : "opacity-60"
}`}
>
<div className="min-w-0 flex-1">
<span className="text-sm text-[var(--text-primary)]">
{item.label}
</span>
<span className="ms-2 font-mono text-xs text-[var(--text-secondary)]">
{item.code}
</span>
{item.is_platform && (
<span className="ms-2 inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-400">
<Lock className="h-3 w-3" />
{t("platform.badge")}
</span>
)}
{!item.is_active && (
<span className="ms-2 text-xs text-[var(--text-secondary)]">
{t("items.retired")}
</span>
)}
</div>
{/* Absent rather than present-and-failing on platform items. A
button that always refuses teaches people to ignore buttons. */}
{!item.is_platform &&
(item.is_active ? (
<CustomButton
variant="secondary"
size="sm"
onClick={() => void retire(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</CustomButton>
) : (
<CustomButton
variant="secondary"
size="sm"
onClick={() => void restore(item)}
>
<Undo2 className="me-2 h-3.5 w-3.5" />
{t("items.restore")}
</CustomButton>
))}
</li>
))}
</ul>
)}
<label className="flex items-center gap-2 text-sm text-[var(--text-primary)]">
<input
type="checkbox"
checked={showRetired}
onChange={(event) => setShowRetired(event.target.checked)}
/>
<span>{t("items.showRetired")}</span>
</label>
{canAdd && (
<div className="space-y-3 rounded-md border border-[var(--card-border)] p-3">
<p className="text-sm font-medium text-[var(--text-primary)]">
{t("items.add")}
</p>
<div className="grid gap-3 sm:grid-cols-2">
<CustomInput
label={t("items.label")}
type="text"
value={label}
onChange={(event) => setLabel(event.target.value)}
/>
<CustomInput
label={t("items.code")}
type="text"
value={code}
onChange={(event) => setCode(event.target.value)}
/>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t("items.codeNote")}
</p>
{error && <p className="text-sm text-red-500">{error}</p>}
<CustomButton
variant="primary"
onClick={add}
disabled={isBusy || !code.trim() || !label.trim()}
>
<Plus className="me-2 h-4 w-4" />
{t("items.addButton")}
</CustomButton>
</div>
)}
</div>
);
};
const ReferencePage: React.FC = () => {
const { t } = useTranslation(["reference", "common"]);
const [lists, setLists] = useState<ReferenceList[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [isBusy, setIsBusy] = useState(false);
const [error, setError] = useState("");
const [open, setOpen] = useState<ReferenceList | null>(null);
const [pendingDelete, setPendingDelete] = useState<ReferenceList | null>(null);
const [deleteError, setDeleteError] = useState("");
const load = useCallback(async () => {
setIsLoading(true);
setFailed(false);
try {
setLists(await referenceApi.lists());
} catch {
setFailed(true);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const create = async () => {
setError("");
setIsBusy(true);
try {
await referenceApi.createList({ code: code.trim(), name: name.trim() });
setIsCreateOpen(false);
setCode("");
setName("");
await load();
} catch (caught) {
setError(caught instanceof Error ? caught.message : t("errors.generic"));
} finally {
setIsBusy(false);
}
};
const remove = async () => {
if (!pendingDelete) return;
const target = pendingDelete;
setPendingDelete(null);
setDeleteError("");
try {
await referenceApi.deleteList(target.id);
} catch (caught) {
// The server refuses while it still holds items, and that refusal is the
// useful part — it says how many.
setDeleteError(
caught instanceof Error ? caught.message : t("errors.generic")
);
} finally {
await load();
}
};
return (
<div className="mx-auto max-w-4xl">
<div className="mb-6 flex flex-wrap items-start justify-between gap-3">
<div>
<h1 className="text-2xl font-bold text-[var(--text-primary)]">
{t("title")}
</h1>
<p className="mt-1 text-sm text-[var(--text-secondary)]">
{t("subtitle")}
</p>
</div>
<CustomButton variant="primary" onClick={() => setIsCreateOpen(true)}>
<Plus className="me-2 h-4 w-4" />
{t("add")}
</CustomButton>
</div>
{deleteError && (
<div className="mb-4 rounded-md border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/20 dark:text-amber-300">
{deleteError}
</div>
)}
<div className="rounded-lg border border-[var(--card-border)] bg-[var(--card-bg)] shadow-sm">
{isLoading ? (
<div className="flex justify-center py-12">
<CustomLoader />
</div>
) : failed ? (
<p className="px-6 py-12 text-center text-sm text-[var(--text-secondary)]">
{t("errors.loadFailed")}
</p>
) : lists.length === 0 ? (
<div className="px-6 py-12 text-center">
<List className="mx-auto mb-3 h-8 w-8 text-[var(--text-secondary)]" />
<p className="text-sm text-[var(--text-secondary)]">{t("empty")}</p>
</div>
) : (
<ul className="divide-y divide-[var(--card-border)]">
{lists.map((list) => (
<li
key={list.id}
className="flex flex-wrap items-center gap-2 px-6 py-3"
>
<div className="min-w-0 flex-1">
<span className="font-medium text-[var(--text-primary)]">
{list.name}
</span>
<span className="ms-2 font-mono text-xs text-[var(--text-secondary)]">
{list.code}
</span>
{list.is_platform && (
<span className="ms-2 inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-400">
<Lock className="h-3 w-3" />
{t("platform.badge")}
</span>
)}
</div>
<CustomButton
variant="secondary"
size="sm"
onClick={() => setOpen(list)}
>
{t("items.open")}
<ChevronRight className="ms-1 h-3.5 w-3.5" />
</CustomButton>
{!list.is_platform && (
<CustomButton
variant="secondary"
size="sm"
onClick={() => setPendingDelete(list)}
>
<Trash2 className="h-3.5 w-3.5" />
</CustomButton>
)}
</li>
))}
</ul>
)}
</div>
<CustomModal
isOpen={isCreateOpen}
onClose={() => {
setIsCreateOpen(false);
setError("");
}}
title={t("add")}
>
<div className="space-y-4">
<CustomInput
label={t("form.name")}
type="text"
placeholder={t("form.namePlaceholder")}
required
value={name}
onChange={(event) => setName(event.target.value)}
/>
<CustomInput
label={t("form.code")}
type="text"
placeholder="cost-centre"
required
value={code}
onChange={(event) => setCode(event.target.value)}
/>
<p className="text-sm text-[var(--text-secondary)]">
{t("form.codeNote")}
</p>
{error && <p className="text-sm text-red-500">{error}</p>}
<CustomButton
variant="primary"
className="w-full"
onClick={create}
disabled={isBusy || !code.trim() || !name.trim()}
>
{t("form.submit")}
</CustomButton>
</div>
</CustomModal>
<CustomModal
isOpen={open !== null}
onClose={() => setOpen(null)}
title={open?.name ?? ""}
>
{open && <ItemsPanel list={open} onChanged={load} />}
</CustomModal>
<CustomConfirmationModal
isOpen={pendingDelete !== null}
onClose={() => setPendingDelete(null)}
onConfirm={remove}
title={t("confirmDelete.title")}
description={t("confirmDelete.message", { name: pendingDelete?.name ?? "" })}
confirmText={t("confirmDelete.confirm")}
/>
</div>
);
};
export default ReferencePage;