Initialize configurable Quick Access module
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-06 19:02:54 +02:00
parent f3cf91b898
commit 3fbbb84267
34 changed files with 3086 additions and 1 deletions
@@ -0,0 +1,370 @@
import { ArrowDown, ArrowUp, RotateCcw, Save } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import {
Button,
DismissibleAlert,
IconButton,
i18nMessage,
LoadingFrame,
SegmentedControl,
ToggleSwitch,
usePlatformLanguage,
useUnsavedDraftGuard,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
loadEffectiveQuickAccess,
loadQuickAccessCatalogue,
loadQuickAccessProfile,
saveQuickAccessProfile,
type EffectiveQuickAccess,
type QuickAccessCatalogue,
type QuickAccessPreference,
type QuickAccessProfile
} from "../../api/quickAccess";
type ProfileScope = "system" | "tenant" | "me";
type Draft = {
categories: Record<string, QuickAccessPreference>;
tools: Record<string, QuickAccessPreference>;
};
type AvailabilityMode = "inherit" | "available" | "blocked" | "forced";
const EMPTY_DRAFT: Draft = { categories: {}, tools: {} };
export default function QuickAccessSettingsPanel({
settings,
auth,
scope,
canWrite
}: {
settings: ApiSettings;
auth: AuthInfo;
scope: ProfileScope;
canWrite: boolean;
}) {
const [catalogue, setCatalogue] = useState<QuickAccessCatalogue | null>(null);
const [effective, setEffective] = useState<EffectiveQuickAccess | null>(null);
const [profile, setProfile] = useState<QuickAccessProfile | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState("");
const [messageTone, setMessageTone] = useState<"success" | "warning">("success");
const { translateText } = usePlatformLanguage();
const isPersonal = scope === "me";
const dirty = useMemo(
() => profile !== null && JSON.stringify(draft) !== JSON.stringify({
categories: profile.category_preferences,
tools: profile.tool_preferences
}),
[draft, profile]
);
useEffect(() => {
void load();
}, [auth.user.account_id, scope, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useUnsavedDraftGuard({
dirty,
onSave: save,
onDiscard: reset
});
async function load() {
setLoading(true);
setMessage("");
const includeAll = scope !== "me";
try {
const [nextCatalogue, nextProfile, nextEffective] = await Promise.all([
loadQuickAccessCatalogue(settings, includeAll),
loadQuickAccessProfile(settings, scope),
loadEffectiveQuickAccess(settings, includeAll)
]);
setCatalogue(nextCatalogue);
setProfile(nextProfile);
setEffective(nextEffective);
setDraft({
categories: nextProfile.category_preferences,
tools: nextProfile.tool_preferences
});
} catch (reason) {
setMessageTone("warning");
setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.load_failed");
} finally {
setLoading(false);
}
}
async function save(): Promise<boolean> {
if (!profile || !canWrite) return false;
setSaving(true);
setMessage("");
try {
const next = await saveQuickAccessProfile(
settings,
scope,
profile,
draft.categories,
draft.tools
);
setProfile(next);
setDraft({ categories: next.category_preferences, tools: next.tool_preferences });
setMessageTone("success");
setMessage("i18n:govoplan-quick-access.saved");
window.dispatchEvent(new CustomEvent("govoplan:quick-access-changed"));
return true;
} catch (reason) {
setMessageTone("warning");
setMessage(reason instanceof Error ? reason.message : "i18n:govoplan-quick-access.save_failed");
return false;
} finally {
setSaving(false);
}
}
function reset() {
if (!profile) return;
setDraft({
categories: profile.category_preferences,
tools: profile.tool_preferences
});
}
function setPreference(
group: "categories" | "tools",
id: string,
preference: QuickAccessPreference | null
) {
setDraft((current) => {
const nextGroup = { ...current[group] };
if (preference === null) delete nextGroup[id];
else nextGroup[id] = preference;
return { ...current, [group]: nextGroup };
});
}
function move(
group: "categories" | "tools",
ids: string[],
id: string,
direction: -1 | 1
) {
const currentIndex = ids.indexOf(id);
const targetIndex = currentIndex + direction;
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= ids.length) return;
const reordered = [...ids];
[reordered[currentIndex], reordered[targetIndex]] = [reordered[targetIndex], reordered[currentIndex]];
setDraft((current) => ({
...current,
[group]: Object.fromEntries(reordered.map((itemId, index) => [
itemId,
{ ...(current[group][itemId] ?? {}), order: (index + 1) * 10 }
]))
}));
}
const categories = useMemo(
() => [...(catalogue?.categories ?? [])].sort((left, right) =>
(draft.categories[left.id]?.order ?? left.order) - (draft.categories[right.id]?.order ?? right.order)
),
[catalogue, draft.categories]
);
const tools = useMemo(
() => [...(catalogue?.tools ?? [])].sort((left, right) => {
if (left.category_id !== right.category_id) return left.category_id.localeCompare(right.category_id);
return (draft.tools[left.id]?.order ?? left.order) - (draft.tools[right.id]?.order ?? right.order);
}),
[catalogue, draft.tools]
);
const lockedCategories = new Map(
(effective?.categories ?? []).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
);
const lockedTools = new Map(
(effective?.categories ?? []).flatMap((category) => category.tools).filter((item) => item.locked_by).map((item) => [item.id, item.locked_by])
);
const categoryIds = categories.map((item) => item.id);
return (
<div className="quick-access-settings">
<div className="quick-access-settings-heading">
<div>
<h2>i18n:govoplan-quick-access.settings_title</h2>
<p>{isPersonal ? "i18n:govoplan-quick-access.personal_help" : "i18n:govoplan-quick-access.admin_help"}</p>
</div>
<div className="quick-access-settings-actions">
<Button variant="secondary" icon={<RotateCcw size={16} />} disabled={!dirty || saving} onClick={reset}>i18n:govoplan-quick-access.discard</Button>
<Button variant="primary" icon={<Save size={16} />} disabled={!dirty || saving || !canWrite} onClick={() => void save()}>i18n:govoplan-quick-access.save</Button>
</div>
</div>
{message ? <DismissibleAlert tone={messageTone} resetKey={message}>{message}</DismissibleAlert> : null}
{profile && (profile.stale_category_ids.length || profile.stale_tool_ids.length) ? (
<DismissibleAlert tone="info" resetKey={`${profile.stale_category_ids.join(",")}:${profile.stale_tool_ids.join(",")}`}>
i18n:govoplan-quick-access.stale_preferences_retained
</DismissibleAlert>
) : null}
<LoadingFrame loading={loading} label="i18n:govoplan-quick-access.loading">
<section className="quick-access-settings-section">
<h3>i18n:govoplan-quick-access.categories</h3>
<div className="quick-access-preference-list">
{categories.map((category, index) => (
<PreferenceRow
key={category.id}
id={category.id}
label={translateText(category.label)}
description={translateText(category.description)}
preference={draft.categories[category.id]}
isPersonal={isPersonal}
lockedBy={editableConstraintSource(scope, lockedCategories.get(category.id))}
disabled={!canWrite || saving}
onChange={(value) => setPreference("categories", category.id, value)}
onMoveUp={() => move("categories", categoryIds, category.id, -1)}
onMoveDown={() => move("categories", categoryIds, category.id, 1)}
first={index === 0}
last={index === categories.length - 1}
/>
))}
</div>
</section>
<section className="quick-access-settings-section">
<h3>i18n:govoplan-quick-access.registered_tools</h3>
<div className="quick-access-preference-list">
{categories.flatMap((category) => {
const categoryTools = tools.filter((tool) => tool.category_id === category.id);
const toolIds = categoryTools.map((tool) => tool.id);
return categoryTools.map((tool, index) => (
<PreferenceRow
key={tool.id}
id={tool.id}
label={translateText(tool.label)}
description={`${translateText(category.label)} · ${tool.module_id}${tool.description ? ` · ${translateText(tool.description)}` : ""}`}
preference={draft.tools[tool.id]}
defaultEnabled={tool.default_enabled}
isPersonal={isPersonal}
lockedBy={editableConstraintSource(scope, lockedTools.get(tool.id))}
disabled={!canWrite || saving}
onChange={(value) => setPreference("tools", tool.id, value)}
onMoveUp={() => move("tools", toolIds, tool.id, -1)}
onMoveDown={() => move("tools", toolIds, tool.id, 1)}
first={index === 0}
last={index === categoryTools.length - 1}
/>
));
})}
{!tools.length ? <p className="quick-access-empty">i18n:govoplan-quick-access.no_registered_tools</p> : null}
</div>
</section>
</LoadingFrame>
</div>
);
}
function PreferenceRow({
id,
label,
description,
preference,
defaultEnabled = true,
isPersonal,
lockedBy,
disabled,
onChange,
onMoveUp,
onMoveDown,
first,
last
}: {
id: string;
label: string;
description: string;
preference?: QuickAccessPreference;
defaultEnabled?: boolean;
isPersonal: boolean;
lockedBy?: string | null;
disabled: boolean;
onChange: (value: QuickAccessPreference | null) => void;
onMoveUp: () => void;
onMoveDown: () => void;
first: boolean;
last: boolean;
}) {
const mode = preferenceMode(preference);
const rowDisabled = disabled || Boolean(lockedBy);
return (
<div className={`quick-access-preference-row${lockedBy ? " is-locked" : ""}`} data-preference-id={id}>
<div className="quick-access-preference-copy">
<strong>{label}</strong>
<small>{description}</small>
{lockedBy ? (
<small>
{i18nMessage("i18n:govoplan-quick-access.locked_by_value", {
value0: lockedBy
})}
</small>
) : null}
</div>
<div className="quick-access-preference-controls">
{isPersonal ? (
<ToggleSwitch
label={label}
inactiveLabel="i18n:govoplan-quick-access.hidden"
activeLabel="i18n:govoplan-quick-access.visible"
checked={preference?.enabled ?? defaultEnabled}
disabled={rowDisabled}
onChange={(enabled) => onChange({ ...preference, enabled })}
/>
) : (
<SegmentedControl
ariaLabel={`${label} availability`}
role="group"
value={mode}
disabled={rowDisabled}
onChange={(value) => onChange(preferenceForMode(value, preference))}
options={[
{ id: "inherit", label: "i18n:govoplan-quick-access.inherit" },
{ id: "available", label: "i18n:govoplan-quick-access.available" },
{ id: "blocked", label: "i18n:govoplan-quick-access.blocked" },
{ id: "forced", label: "i18n:govoplan-quick-access.forced" }
]}
/>
)}
<div className="quick-access-order-buttons">
<IconButton label="i18n:govoplan-quick-access.move_up" icon={<ArrowUp size={16} />} variant="ghost" disabled={disabled || first} onClick={onMoveUp} />
<IconButton label="i18n:govoplan-quick-access.move_down" icon={<ArrowDown size={16} />} variant="ghost" disabled={disabled || last} onClick={onMoveDown} />
</div>
</div>
</div>
);
}
function preferenceMode(preference?: QuickAccessPreference): AvailabilityMode {
if (!preference || preference.enabled === null || preference.enabled === undefined) return "inherit";
if (preference.enabled === false) return "blocked";
return preference.forced ? "forced" : "available";
}
function editableConstraintSource(
scope: ProfileScope,
source: string | null | undefined
): string | null {
if (!source || scope === "system") return null;
if (scope === "tenant") return source === "system" ? source : null;
return source;
}
function preferenceForMode(
mode: AvailabilityMode,
current?: QuickAccessPreference
): QuickAccessPreference | null {
if (mode === "inherit") return current?.order === undefined ? null : { order: current.order };
if (mode === "blocked") return { ...current, enabled: false, forced: false };
if (mode === "forced") return { ...current, enabled: true, forced: true };
return { ...current, enabled: true, forced: false };
}