431 lines
14 KiB
TypeScript
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;
|