404 lines
14 KiB
TypeScript
404 lines
14 KiB
TypeScript
import { ArrowDown, ArrowUp, RotateCcw, Save } from "lucide-react";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import {
|
|
Button,
|
|
DismissibleAlert,
|
|
FormSection,
|
|
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";
|
|
type EffectiveProvenance = {
|
|
state: "available" | "forced" | "blocked";
|
|
availabilitySource: "module" | "system" | "tenant" | "user";
|
|
orderSource: "module" | "system" | "tenant" | "user";
|
|
};
|
|
|
|
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 categoryProvenance = new Map(
|
|
(effective?.categories ?? []).map((item) => [item.id, effectiveProvenance(item)])
|
|
);
|
|
const toolProvenance = new Map(
|
|
(effective?.categories ?? []).flatMap((category) =>
|
|
category.tools.map((item) => [item.id, effectiveProvenance(item)] as const)
|
|
)
|
|
);
|
|
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" disabled={!dirty || saving} onClick={reset}><RotateCcw size={16} aria-hidden="true" />i18n:govoplan-quick-access.discard</Button>
|
|
<Button variant="primary" disabled={!dirty || saving || !canWrite} onClick={() => void save()}><Save size={16} aria-hidden="true" />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">
|
|
<FormSection className="quick-access-settings-section" title="i18n:govoplan-quick-access.categories" contentClassName="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))}
|
|
provenance={categoryProvenance.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}
|
|
/>
|
|
))}
|
|
</FormSection>
|
|
|
|
<FormSection className="quick-access-settings-section" title="i18n:govoplan-quick-access.registered_tools" contentClassName="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))}
|
|
provenance={toolProvenance.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}
|
|
</FormSection>
|
|
</LoadingFrame>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
function PreferenceRow({
|
|
id,
|
|
label,
|
|
description,
|
|
preference,
|
|
defaultEnabled = true,
|
|
isPersonal,
|
|
lockedBy,
|
|
provenance,
|
|
disabled,
|
|
onChange,
|
|
onMoveUp,
|
|
onMoveDown,
|
|
first,
|
|
last
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
description: string;
|
|
preference?: QuickAccessPreference;
|
|
defaultEnabled?: boolean;
|
|
isPersonal: boolean;
|
|
lockedBy?: string | null;
|
|
provenance?: EffectiveProvenance;
|
|
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}
|
|
{provenance ? (
|
|
<small>
|
|
{i18nMessage("i18n:govoplan-quick-access.effective_provenance", {
|
|
value0: provenance.state,
|
|
value1: provenance.availabilitySource,
|
|
value2: provenance.orderSource
|
|
})}
|
|
</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 effectiveProvenance(item: {
|
|
availability_state: "available" | "forced" | "blocked";
|
|
availability_source: "module" | "system" | "tenant" | "user";
|
|
order_source: "module" | "system" | "tenant" | "user";
|
|
}): EffectiveProvenance {
|
|
return {
|
|
state: item.availability_state,
|
|
availabilitySource: item.availability_source,
|
|
orderSource: item.order_source
|
|
};
|
|
}
|
|
|
|
|
|
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 };
|
|
}
|