In der Nutzerverwaltung kannst du jetzt pro Konto die Rolle, die AG-Zuordnung und die Freigaberollen bearbeiten. Die feste 3er-Freigabelogik bleibt Vorstand A / Vorstand B / Finanz-AG, aber jetzt legst du über die Nutzer fest, wer diese Schritte autorisieren darf. Zusätzlich gibt es unter Nutzer anlegen eine eigene Insel für die Freigabe-Schwelle, und diese Schwelle wird jetzt auch wirklich überall verwendet: in der Erfassungslogik, in den Budgetkarten, im CSV-Backup/-Import und im Audit-Restore. Die Hauptänderungen sitzen in dashboard-shell.tsx, budget-column.tsx, route.ts, schema.prisma und route.ts.
All checks were successful
CI / build-and-deploy (push) Successful in 1m22s
All checks were successful
CI / build-and-deploy (push) Successful in 1m22s
Den Zeitraum-Bereich habe ich dabei gleich mit aufgeräumt: die Auswahl des aktuellen Haushalts ist breiter und sauberer angeordnet, und die Desktop-Nutzerverwaltung ist jetzt wirklich links Anlegen + Schwelle und rechts die Nutzerliste. Seed und Backup/Restore kennen die neuen Felder ebenfalls in seed.ts, route.ts und route.ts.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
||||
import DeleteOutlineRoundedIcon from "@mui/icons-material/DeleteOutlineRounded";
|
||||
import DownloadRoundedIcon from "@mui/icons-material/DownloadRounded";
|
||||
import EditRoundedIcon from "@mui/icons-material/EditRounded";
|
||||
import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
|
||||
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
|
||||
import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
|
||||
@@ -40,9 +41,11 @@ import type {
|
||||
DashboardWorkingGroup
|
||||
} from "@/lib/dashboard-types";
|
||||
import {
|
||||
AUTO_APPROVAL_THRESHOLD,
|
||||
APPROVAL_FLOW,
|
||||
approvalLabel,
|
||||
canManageBudgets,
|
||||
canManageUsers,
|
||||
getDefaultApprovalPermissionsForRole,
|
||||
roleLabel
|
||||
} from "@/lib/domain";
|
||||
|
||||
@@ -53,6 +56,7 @@ type DashboardShellProps = {
|
||||
auditLogs: DashboardAuditLog[];
|
||||
accountingPeriods: DashboardAccountingPeriod[];
|
||||
currentPeriodId: string;
|
||||
approvalThreshold: number;
|
||||
};
|
||||
|
||||
type ExpenseFormState = {
|
||||
@@ -76,12 +80,20 @@ type WorkingGroupFormState = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ApprovalPermissionValue = (typeof APPROVAL_FLOW)[number];
|
||||
|
||||
type UserFormState = {
|
||||
username: string;
|
||||
password: string;
|
||||
role: "ADMIN" | "FINANCE" | "MEMBER";
|
||||
workingGroupId: string;
|
||||
approvalPreference: "" | "CHAIR_A" | "CHAIR_B";
|
||||
approvalPermissions: ApprovalPermissionValue[];
|
||||
};
|
||||
|
||||
type ManagedUserDraft = {
|
||||
role: "ADMIN" | "FINANCE" | "MEMBER";
|
||||
workingGroupId: string;
|
||||
approvalPermissions: ApprovalPermissionValue[];
|
||||
};
|
||||
|
||||
type PeriodFormState = {
|
||||
@@ -96,9 +108,21 @@ type DashboardMessage = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
function sortApprovalPermissions(value: ApprovalPermissionValue[]) {
|
||||
return APPROVAL_FLOW.filter((approvalType) => value.includes(approvalType));
|
||||
}
|
||||
|
||||
function toggleApprovalPermission(
|
||||
currentValue: ApprovalPermissionValue[],
|
||||
approvalType: ApprovalPermissionValue
|
||||
) {
|
||||
return currentValue.includes(approvalType)
|
||||
? currentValue.filter((entry) => entry !== approvalType)
|
||||
: sortApprovalPermissions([...currentValue, approvalType]);
|
||||
}
|
||||
|
||||
type MobileSection = "overview" | "actions";
|
||||
type DesktopSection = "overview" | "budgetGroups" | "periods" | "users" | "logs";
|
||||
|
||||
const currencyFormatter = new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR"
|
||||
@@ -171,7 +195,8 @@ export function DashboardShell({
|
||||
managedUsers,
|
||||
auditLogs,
|
||||
accountingPeriods,
|
||||
currentPeriodId
|
||||
currentPeriodId,
|
||||
approvalThreshold
|
||||
}: DashboardShellProps) {
|
||||
const theme = useTheme();
|
||||
const isDark = theme.palette.mode === "dark";
|
||||
@@ -224,7 +249,7 @@ export function DashboardShell({
|
||||
password: "",
|
||||
role: "MEMBER",
|
||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||
approvalPreference: ""
|
||||
approvalPermissions: []
|
||||
});
|
||||
const [message, setMessage] = useState<DashboardMessage | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -236,9 +261,11 @@ export function DashboardShell({
|
||||
);
|
||||
const [backupFile, setBackupFile] = useState<File | null>(null);
|
||||
const [editingPasswordUserId, setEditingPasswordUserId] = useState<string | null>(null);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
const [passwordDrafts, setPasswordDrafts] = useState<Record<string, string>>({});
|
||||
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
|
||||
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
|
||||
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
|
||||
|
||||
useEffect(() => {
|
||||
if (visibleGroups.length === 0) {
|
||||
setSelectedMobileGroupId("");
|
||||
@@ -321,10 +348,6 @@ export function DashboardShell({
|
||||
}, [defaultEditableGroup, editableExpenseGroups, expenseForm.agId, expenseForm.budgetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userForm.role !== "MEMBER") {
|
||||
return;
|
||||
}
|
||||
|
||||
const groupStillExists = visibleGroups.some((group) => group.id === userForm.workingGroupId);
|
||||
|
||||
if (!groupStillExists) {
|
||||
@@ -333,8 +356,17 @@ export function DashboardShell({
|
||||
workingGroupId: visibleGroups[0]?.id ?? ""
|
||||
}));
|
||||
}
|
||||
}, [userForm.role, userForm.workingGroupId, visibleGroups]);
|
||||
}, [userForm.workingGroupId, visibleGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
setApprovalThresholdDraft(approvalThreshold.toFixed(2));
|
||||
}, [approvalThreshold]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingUserId && !managedUsers.some((user) => user.id === editingUserId)) {
|
||||
setEditingUserId(null);
|
||||
}
|
||||
}, [editingUserId, managedUsers]);
|
||||
const selectedExpenseGroup =
|
||||
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
|
||||
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
|
||||
@@ -344,6 +376,34 @@ export function DashboardShell({
|
||||
const selectedPeriodForManagement =
|
||||
accountingPeriods.find((period) => period.id === selectedCurrentPeriodId) ?? currentPeriod ?? null;
|
||||
|
||||
function getManagedUserDraft(user: DashboardManagedUser): ManagedUserDraft {
|
||||
return userDrafts[user.id] ?? {
|
||||
role: user.role,
|
||||
workingGroupId: user.workingGroupId ?? "",
|
||||
approvalPermissions: sortApprovalPermissions(user.approvalPermissions)
|
||||
};
|
||||
}
|
||||
|
||||
function updateManagedUserDraft(user: DashboardManagedUser, patch: Partial<ManagedUserDraft>) {
|
||||
setUserDrafts((current) => ({
|
||||
...current,
|
||||
[user.id]: {
|
||||
...getManagedUserDraft(user),
|
||||
...patch
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function resetManagedUserDraft(user: DashboardManagedUser) {
|
||||
setUserDrafts((current) => ({
|
||||
...current,
|
||||
[user.id]: {
|
||||
role: user.role,
|
||||
workingGroupId: user.workingGroupId ?? "",
|
||||
approvalPermissions: sortApprovalPermissions(user.approvalPermissions)
|
||||
}
|
||||
}));
|
||||
}
|
||||
const totals = useMemo(() => {
|
||||
return visibleGroups.reduce(
|
||||
(summary, group) => {
|
||||
@@ -693,8 +753,8 @@ export function DashboardShell({
|
||||
username: createdUsername,
|
||||
password: userForm.password,
|
||||
role: userForm.role,
|
||||
workingGroupId: userForm.role === "MEMBER" ? userForm.workingGroupId : "",
|
||||
approvalPreference: userForm.role === "ADMIN" ? userForm.approvalPreference : ""
|
||||
workingGroupId: userForm.workingGroupId,
|
||||
approvalPermissions: sortApprovalPermissions(userForm.approvalPermissions)
|
||||
})
|
||||
})
|
||||
);
|
||||
@@ -704,7 +764,7 @@ export function DashboardShell({
|
||||
password: "",
|
||||
role: "MEMBER",
|
||||
workingGroupId: visibleGroups[0]?.id ?? "",
|
||||
approvalPreference: ""
|
||||
approvalPermissions: []
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -713,10 +773,57 @@ export function DashboardShell({
|
||||
};
|
||||
},
|
||||
({ createdUsername, createdPassword }) =>
|
||||
`Nutzer wurde angelegt. Startpasswort f\u00fcr ${createdUsername}: ${createdPassword}`
|
||||
`Nutzer wurde angelegt. Startpasswort fuer ${createdUsername}: ${createdPassword}`
|
||||
);
|
||||
}
|
||||
|
||||
async function handleUpdateUser(user: DashboardManagedUser) {
|
||||
const draft = getManagedUserDraft(user);
|
||||
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch(`/api/users/${user.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
role: draft.role,
|
||||
workingGroupId: draft.workingGroupId,
|
||||
approvalPermissions: sortApprovalPermissions(draft.approvalPermissions)
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
setEditingUserId(null);
|
||||
}, `Nutzer ${user.username} wurde aktualisiert.`);
|
||||
}
|
||||
|
||||
async function handleSaveApprovalThreshold() {
|
||||
const nextThreshold = Number(approvalThresholdDraft.replace(",", "."));
|
||||
|
||||
if (!Number.isFinite(nextThreshold) || nextThreshold < 0) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Bitte eine gueltige Freigabe-Schwelle eingeben."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
approvalThreshold: nextThreshold
|
||||
})
|
||||
})
|
||||
);
|
||||
}, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`);
|
||||
}
|
||||
async function handleDeleteUser(userId: string) {
|
||||
await runAction(async () => {
|
||||
await parseResponse(
|
||||
@@ -768,7 +875,7 @@ export function DashboardShell({
|
||||
if (!backupFile) {
|
||||
setMessage({
|
||||
type: "error",
|
||||
text: "Bitte zuerst eine CSV-Datei auswählen."
|
||||
text: "Bitte zuerst eine CSV-Datei auswählen."
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -794,7 +901,7 @@ export function DashboardShell({
|
||||
}
|
||||
|
||||
async function handleRestoreAuditLog(entryId: string, summary: string) {
|
||||
if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) {
|
||||
if (!window.confirm(`Diesen Zustand wirklich zurücksetzen?\n\n${summary}`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -804,7 +911,7 @@ export function DashboardShell({
|
||||
method: "POST"
|
||||
})
|
||||
);
|
||||
}, "Änderung wurde zurückgesetzt.");
|
||||
}, "Änderung wurde zurückgesetzt.");
|
||||
}
|
||||
|
||||
function openPasswordReset(userId: string) {
|
||||
@@ -815,6 +922,44 @@ export function DashboardShell({
|
||||
}));
|
||||
}
|
||||
|
||||
function openUserEditor(user: DashboardManagedUser) {
|
||||
resetManagedUserDraft(user);
|
||||
setEditingUserId(user.id);
|
||||
}
|
||||
|
||||
function renderApprovalPermissionSelector(
|
||||
value: ApprovalPermissionValue[],
|
||||
onToggle: (approvalType: ApprovalPermissionValue) => void,
|
||||
helperText: string
|
||||
) {
|
||||
return (
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
Freigaberollen
|
||||
</Typography>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
{APPROVAL_FLOW.map((approvalType) => {
|
||||
const selected = value.includes(approvalType);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={approvalType}
|
||||
type="button"
|
||||
size="small"
|
||||
variant={selected ? "contained" : "outlined"}
|
||||
onClick={() => onToggle(approvalType)}
|
||||
>
|
||||
{approvalLabel(approvalType)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{helperText}
|
||||
</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
const islandCardSx = {
|
||||
borderRadius: { xs: "24px", md: "30px" },
|
||||
border: `1px solid ${alpha(theme.palette.text.primary, isDark ? 0.12 : 0.08)}`,
|
||||
@@ -838,7 +983,7 @@ export function DashboardShell({
|
||||
Zeitraum wechseln
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{"Nur Vorstand und Finanz-AG k\u00f6nnen die aktuelle \u00dcbersicht global umstellen."}
|
||||
{"Nur Vorstand und Finanz-AG koennen die aktuelle Uebersicht global umstellen."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box
|
||||
@@ -846,18 +991,19 @@ export function DashboardShell({
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "minmax(0, 1fr) 148px 184px"
|
||||
md: "minmax(0, 1.45fr) minmax(168px, 0.85fr) minmax(220px, 0.95fr)"
|
||||
},
|
||||
gap: 1.2,
|
||||
alignItems: "start"
|
||||
alignItems: "stretch"
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
select
|
||||
label={"Aktuelle \u00dcbersicht"}
|
||||
label={"Aktuelle Uebersicht"}
|
||||
value={selectedCurrentPeriodId}
|
||||
onChange={(event) => setSelectedCurrentPeriodId(event.target.value)}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
sx={{ minWidth: 0 }}
|
||||
>
|
||||
{accountingPeriods.map((period) => (
|
||||
@@ -870,12 +1016,9 @@ export function DashboardShell({
|
||||
variant="contained"
|
||||
disabled={busy || selectedCurrentPeriodId === currentPeriodId}
|
||||
onClick={handleSetCurrentPeriod}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
px: { sm: 1.75 }
|
||||
}}
|
||||
sx={{ minWidth: 0, minHeight: 56, px: 2 }}
|
||||
>
|
||||
{"\u00dcbersicht setzen"}
|
||||
{"Uebersicht setzen"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -888,7 +1031,7 @@ export function DashboardShell({
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm(`Zeitraum "${selectedPeriodForManagement.name}" wirklich l\u00f6schen?`)) {
|
||||
if (!window.confirm(`Zeitraum "${selectedPeriodForManagement.name}" wirklich loeschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -896,17 +1039,18 @@ export function DashboardShell({
|
||||
}}
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
px: { sm: 2.25 },
|
||||
whiteSpace: "nowrap"
|
||||
minHeight: 56,
|
||||
px: 2.5,
|
||||
whiteSpace: "normal"
|
||||
}}
|
||||
>
|
||||
{"Zeitraum l\u00f6schen"}
|
||||
{"Zeitraum loeschen"}
|
||||
</Button>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{selectedPeriodForManagement?.isCurrent
|
||||
? "Der aktuell aktive Zeitraum kann nicht gel\u00f6scht werden."
|
||||
: "Leere, nicht aktive Zeitr\u00e4ume lassen sich hier wieder entfernen."}
|
||||
? "Der aktuell aktive Zeitraum kann nicht geloescht werden."
|
||||
: "Leere, nicht aktive Zeitraeume lassen sich hier wieder entfernen."}
|
||||
</Typography>
|
||||
|
||||
<Box component="form" onSubmit={handleCreatePeriod} sx={nestedPanelSx}>
|
||||
@@ -943,7 +1087,7 @@ export function DashboardShell({
|
||||
</Stack>
|
||||
<TextField
|
||||
select
|
||||
label={"Budgets \u00fcbernehmen"}
|
||||
label={"Budgets uebernehmen"}
|
||||
value={periodForm.copyBudgetsFromPeriodId}
|
||||
onChange={(event) =>
|
||||
setPeriodForm((current) => ({
|
||||
@@ -952,9 +1096,9 @@ export function DashboardShell({
|
||||
}))
|
||||
}
|
||||
fullWidth
|
||||
helperText={"Optional kopiert die vorhandenen Budgett\u00f6pfe direkt in den neuen Zeitraum."}
|
||||
>
|
||||
<MenuItem value="">{`Ohne Budget\u00fcbernahme`}</MenuItem>
|
||||
helperText={"Optional kopiert die vorhandenen Budgettoepfe direkt in den neuen Zeitraum."}
|
||||
>
|
||||
<MenuItem value="">Ohne Budgetuebernahme</MenuItem>
|
||||
{accountingPeriods.map((period) => (
|
||||
<MenuItem key={period.id} value={period.id}>
|
||||
{period.name}
|
||||
@@ -968,7 +1112,6 @@ export function DashboardShell({
|
||||
</Box>
|
||||
</Stack>
|
||||
) : null;
|
||||
|
||||
const actionCards = (
|
||||
<Stack
|
||||
spacing={!isCompactLayout && (desktopSection === "users" || desktopSection === "budgetGroups") ? 0 : 3}
|
||||
@@ -999,7 +1142,7 @@ export function DashboardShell({
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
{"Alle sehen alle AGs. AG-Mitglieder buchen aber nur in ihrer eigenen AG. Unter "}
|
||||
{AUTO_APPROVAL_THRESHOLD}
|
||||
{approvalThreshold.toFixed(2)}
|
||||
{" EUR wird automatisch freigegeben."}
|
||||
</Typography>
|
||||
</Box>
|
||||
@@ -1238,7 +1381,7 @@ export function DashboardShell({
|
||||
CSV-Backup
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
{"Exportiert Nutzer, AGs, Budgets, Ausgaben, Freigaben und den Änderungsverlauf in eine gemeinsame CSV-Datei."}
|
||||
{"Exportiert Nutzer, AGs, Budgets, Ausgaben, Freigaben und den Änderungsverlauf in eine gemeinsame CSV-Datei."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} useFlexGap flexWrap="wrap">
|
||||
@@ -1251,7 +1394,7 @@ export function DashboardShell({
|
||||
CSV herunterladen
|
||||
</Button>
|
||||
<Button component="label" variant="outlined" disabled={busy}>
|
||||
CSV auswählen
|
||||
CSV auswählen
|
||||
<input
|
||||
hidden
|
||||
type="file"
|
||||
@@ -1270,8 +1413,8 @@ export function DashboardShell({
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{backupFile
|
||||
? `Ausgewählt: ${backupFile.name}`
|
||||
: "Der Import ersetzt den aktuellen Datenbestand vollständig durch den Stand aus der CSV."}
|
||||
? `Ausgewählt: ${backupFile.name}`
|
||||
: "Der Import ersetzt den aktuellen Datenbestand vollständig durch den Stand aus der CSV."}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
@@ -1279,116 +1422,141 @@ export function DashboardShell({
|
||||
) : null}
|
||||
|
||||
{canManageAccounts && (isCompactLayout || desktopSection === "users") ? (
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
|
||||
Nutzer anlegen
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
{"Konten werden direkt mit Login-Name und Passwort angelegt. Der Login-Name ist gleichzeitig der Anzeigename."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="form" onSubmit={handleCreateUser}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Login-Name"
|
||||
helperText={"Damit melden sich Nutzer sp\u00e4ter an und so werden sie auch angezeigt."}
|
||||
value={userForm.username}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, username: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
||||
<Stack spacing={3}>
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2.5}>
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontSize: "1.35rem" }}>
|
||||
Nutzer anlegen
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
{"Konten werden direkt mit Login-Name und Passwort angelegt. Der Login-Name ist gleichzeitig der Anzeigename."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box component="form" onSubmit={handleCreateUser}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
label="Startpasswort"
|
||||
value={userForm.password}
|
||||
onChange={(event) =>
|
||||
setUserForm((current) => ({ ...current, password: event.target.value }))
|
||||
}
|
||||
label="Login-Name"
|
||||
helperText={"Damit melden sich Nutzer spaeter an und so werden sie auch angezeigt."}
|
||||
value={userForm.username}
|
||||
onChange={(event) => setUserForm((current) => ({ ...current, username: event.target.value }))}
|
||||
required
|
||||
fullWidth
|
||||
helperText={"Dieses Passwort wird nach dem Anlegen oben als Best\u00e4tigung angezeigt."}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
setUserForm((current) => ({
|
||||
...current,
|
||||
password: generatePassword()
|
||||
}))
|
||||
}
|
||||
sx={{ minWidth: { sm: 148 } }}
|
||||
>
|
||||
Generieren
|
||||
</Button>
|
||||
</Stack>
|
||||
<TextField
|
||||
select
|
||||
label="Rolle"
|
||||
value={userForm.role}
|
||||
onChange={(event) =>
|
||||
setUserForm((current) => ({
|
||||
...current,
|
||||
role: event.target.value as UserFormState["role"],
|
||||
approvalPreference: event.target.value === "ADMIN" ? current.approvalPreference : "",
|
||||
workingGroupId: event.target.value === "MEMBER" ? current.workingGroupId : ""
|
||||
}))
|
||||
}
|
||||
required
|
||||
>
|
||||
<MenuItem value="ADMIN">Vorstand</MenuItem>
|
||||
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
|
||||
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
|
||||
</TextField>
|
||||
|
||||
{userForm.role === "MEMBER" ? (
|
||||
<Stack direction={{ xs: "column", sm: "row" }} gap={1}>
|
||||
<TextField
|
||||
label="Startpasswort"
|
||||
value={userForm.password}
|
||||
onChange={(event) =>
|
||||
setUserForm((current) => ({ ...current, password: event.target.value }))
|
||||
}
|
||||
required
|
||||
fullWidth
|
||||
helperText={"Dieses Passwort wird nach dem Anlegen oben als Bestaetigung angezeigt."}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
onClick={() =>
|
||||
setUserForm((current) => ({
|
||||
...current,
|
||||
password: generatePassword()
|
||||
}))
|
||||
}
|
||||
sx={{ minWidth: { sm: 148 } }}
|
||||
>
|
||||
Generieren
|
||||
</Button>
|
||||
</Stack>
|
||||
<TextField
|
||||
select
|
||||
label="Arbeitsgruppe"
|
||||
label="Rolle"
|
||||
value={userForm.role}
|
||||
onChange={(event) => {
|
||||
const nextRole = event.target.value as UserFormState["role"];
|
||||
setUserForm((current) => ({
|
||||
...current,
|
||||
role: nextRole,
|
||||
approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole))
|
||||
}));
|
||||
}}
|
||||
required
|
||||
>
|
||||
<MenuItem value="ADMIN">Vorstand</MenuItem>
|
||||
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
|
||||
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="AG-Zuordnung"
|
||||
value={userForm.workingGroupId}
|
||||
onChange={(event) =>
|
||||
setUserForm((current) => ({ ...current, workingGroupId: event.target.value }))
|
||||
}
|
||||
required
|
||||
fullWidth
|
||||
disabled={visibleGroups.length === 0}
|
||||
required={userForm.role === "MEMBER"}
|
||||
helperText={
|
||||
visibleGroups.length === 0
|
||||
? "Lege zuerst eine AG an."
|
||||
: userForm.role === "MEMBER"
|
||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
||||
: "Optional: Auch Vorstand und Finanz-AG koennen einer AG zugeordnet werden."
|
||||
}
|
||||
>
|
||||
{userForm.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
||||
{visibleGroups.map((group) => (
|
||||
<MenuItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
) : null}
|
||||
|
||||
{userForm.role === "ADMIN" ? (
|
||||
<TextField
|
||||
select
|
||||
label={"Prim\u00e4re Freigaberolle"}
|
||||
value={userForm.approvalPreference}
|
||||
onChange={(event) =>
|
||||
{renderApprovalPermissionSelector(
|
||||
userForm.approvalPermissions,
|
||||
(approvalType) =>
|
||||
setUserForm((current) => ({
|
||||
...current,
|
||||
approvalPreference: event.target.value as UserFormState["approvalPreference"]
|
||||
}))
|
||||
}
|
||||
>
|
||||
<MenuItem value="">Keine Voreinstellung</MenuItem>
|
||||
<MenuItem value="CHAIR_A">Vorstand A</MenuItem>
|
||||
<MenuItem value="CHAIR_B">Vorstand B</MenuItem>
|
||||
</TextField>
|
||||
) : null}
|
||||
approvalPermissions: toggleApprovalPermission(current.approvalPermissions, approvalType)
|
||||
})),
|
||||
"Lege fest, fuer welche Freigabeschritte dieses Konto zeichnen darf."
|
||||
)}
|
||||
<Button type="submit" variant="outlined" disabled={busy}>
|
||||
Nutzer speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button type="submit" variant="outlined" disabled={busy}>
|
||||
Nutzer speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
<Stack spacing={2}>
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontSize: "1.2rem" }}>
|
||||
Freigabe-Schwelle
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
{"Ausgaben unter diesem Betrag werden automatisch freigegeben."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<TextField
|
||||
label="Schwelle in EUR"
|
||||
type="number"
|
||||
inputProps={{ min: 0, step: 0.01 }}
|
||||
value={approvalThresholdDraft}
|
||||
onChange={(event) => setApprovalThresholdDraft(event.target.value)}
|
||||
helperText={`Aktuell: ${approvalThreshold.toFixed(2)} EUR`}
|
||||
fullWidth
|
||||
/>
|
||||
<Button type="button" variant="outlined" disabled={busy} onClick={handleSaveApprovalThreshold}>
|
||||
Schwelle speichern
|
||||
</Button>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Stack>
|
||||
) : null}
|
||||
|
||||
{canManageAccounts && (isCompactLayout || desktopSection === "users") ? (
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
@@ -1398,13 +1566,15 @@ export function DashboardShell({
|
||||
Nutzer verwalten
|
||||
</Typography>
|
||||
<Typography color="text.secondary">
|
||||
{"Bestehende Passw\u00f6rter bleiben sicher gehasht und sind nicht auslesbar. Hier kannst du neue setzen und direkt sehen."}
|
||||
{"Bestehende Passwoerter bleiben sicher gehasht. Hier kannst du Rolle, AG-Zuordnung, Freigaberollen und Passwoerter pflegen."}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack spacing={1.4}>
|
||||
{managedUsers.map((user) => {
|
||||
const canDelete = user.id !== viewer.id && user.createdExpensesCount === 0 && user.approvalsCount === 0;
|
||||
const isResetOpen = editingPasswordUserId === user.id;
|
||||
const isEditingUser = editingUserId === user.id;
|
||||
const draft = getManagedUserDraft(user);
|
||||
|
||||
return (
|
||||
<Box key={user.id} sx={nestedPanelSx}>
|
||||
@@ -1422,6 +1592,22 @@ export function DashboardShell({
|
||||
</Typography>
|
||||
</Box>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
<Button
|
||||
size="small"
|
||||
variant={isEditingUser ? "contained" : "outlined"}
|
||||
startIcon={<EditRoundedIcon />}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (isEditingUser) {
|
||||
setEditingUserId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
openUserEditor(user);
|
||||
}}
|
||||
>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant={isResetOpen ? "contained" : "outlined"}
|
||||
@@ -1445,14 +1631,14 @@ export function DashboardShell({
|
||||
startIcon={<DeleteOutlineRoundedIcon />}
|
||||
disabled={busy || !canDelete}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Nutzer "${user.username}" wirklich l\u00f6schen?`)) {
|
||||
if (!window.confirm(`Nutzer "${user.username}" wirklich loeschen?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDeleteUser(user.id);
|
||||
}}
|
||||
>
|
||||
{"L\u00f6schen"}
|
||||
{"Loeschen"}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
@@ -1460,8 +1646,93 @@ export function DashboardShell({
|
||||
<Chip label={user.workingGroupName ?? "Ohne AG"} size="small" variant="outlined" />
|
||||
<Chip label={`Ausgaben: ${user.createdExpensesCount}`} size="small" variant="outlined" />
|
||||
<Chip label={`Freigaben: ${user.approvalsCount}`} size="small" variant="outlined" />
|
||||
{user.approvalPermissions.length > 0 ? (
|
||||
user.approvalPermissions.map((approvalType) => (
|
||||
<Chip key={approvalType} label={approvalLabel(approvalType)} size="small" color="primary" variant="outlined" />
|
||||
))
|
||||
) : (
|
||||
<Chip label="Keine Freigaberolle" size="small" variant="outlined" />
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{isEditingUser ? (
|
||||
<Box
|
||||
sx={{
|
||||
...nestedPanelSx,
|
||||
p: 1.5,
|
||||
borderColor: alpha(theme.palette.primary.main, 0.18),
|
||||
backgroundColor: alpha(theme.palette.primary.main, isDark ? 0.14 : 0.06)
|
||||
}}
|
||||
>
|
||||
<Stack spacing={1.4}>
|
||||
<TextField
|
||||
select
|
||||
label="Rolle"
|
||||
value={draft.role}
|
||||
onChange={(event) => {
|
||||
const nextRole = event.target.value as ManagedUserDraft["role"];
|
||||
updateManagedUserDraft(user, {
|
||||
role: nextRole,
|
||||
approvalPermissions: sortApprovalPermissions(getDefaultApprovalPermissionsForRole(nextRole))
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
>
|
||||
<MenuItem value="ADMIN">Vorstand</MenuItem>
|
||||
<MenuItem value="FINANCE">Finanz-AG</MenuItem>
|
||||
<MenuItem value="MEMBER">AG-Mitglied</MenuItem>
|
||||
</TextField>
|
||||
<TextField
|
||||
select
|
||||
label="AG-Zuordnung"
|
||||
value={draft.workingGroupId}
|
||||
onChange={(event) => updateManagedUserDraft(user, { workingGroupId: event.target.value })}
|
||||
fullWidth
|
||||
disabled={visibleGroups.length === 0}
|
||||
required={draft.role === "MEMBER"}
|
||||
helperText={
|
||||
visibleGroups.length === 0
|
||||
? "Lege zuerst eine AG an."
|
||||
: draft.role === "MEMBER"
|
||||
? "AG-Mitglieder brauchen eine feste AG-Zuordnung."
|
||||
: "Optional: Auch Vorstand und Finanz-AG koennen einer AG zugeordnet werden."
|
||||
}
|
||||
>
|
||||
{draft.role !== "MEMBER" ? <MenuItem value="">Ohne AG</MenuItem> : null}
|
||||
{visibleGroups.map((group) => (
|
||||
<MenuItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</TextField>
|
||||
{renderApprovalPermissionSelector(
|
||||
draft.approvalPermissions,
|
||||
(approvalType) =>
|
||||
updateManagedUserDraft(user, {
|
||||
approvalPermissions: toggleApprovalPermission(draft.approvalPermissions, approvalType)
|
||||
}),
|
||||
"Lege fest, welche Freigabeschritte dieses Konto autorisieren darf."
|
||||
)}
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
<Button type="button" variant="contained" disabled={busy} onClick={() => handleUpdateUser(user)}>
|
||||
Nutzer speichern
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="text"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
resetManagedUserDraft(user);
|
||||
setEditingUserId(null);
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{isResetOpen ? (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -1483,7 +1754,7 @@ export function DashboardShell({
|
||||
}
|
||||
fullWidth
|
||||
helperText={
|
||||
"Nur neu gesetzte Passw\u00f6rter sind sichtbar. Das alte Passwort bleibt absichtlich verborgen."
|
||||
"Nur neu gesetzte Passwoerter sind sichtbar. Das alte Passwort bleibt absichtlich verborgen."
|
||||
}
|
||||
/>
|
||||
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
|
||||
@@ -1529,13 +1800,6 @@ export function DashboardShell({
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{canManagePeriods && isCompactLayout ? (
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{canManageAccounts && (isCompactLayout || desktopSection === "logs") ? (
|
||||
<Card sx={islandCardSx}>
|
||||
<CardContent sx={{ p: 3 }}>
|
||||
@@ -1585,7 +1849,7 @@ export function DashboardShell({
|
||||
onClick={() => handleRestoreAuditLog(entry.id, entry.summary)}
|
||||
sx={{ alignSelf: "flex-start" }}
|
||||
>
|
||||
Zustand zurücksetzen
|
||||
Zustand zurücksetzen
|
||||
</Button>
|
||||
) : null}
|
||||
</Stack>
|
||||
@@ -1687,14 +1951,14 @@ export function DashboardShell({
|
||||
group={group}
|
||||
viewer={viewer}
|
||||
busy={busy}
|
||||
approvalThreshold={approvalThreshold}
|
||||
onApprove={handleApprove}
|
||||
onMarkPaid={handleMarkPaid}
|
||||
onDocument={handleDocument}
|
||||
onSaveWorkingGroup={handleSaveWorkingGroup}
|
||||
onDeleteWorkingGroup={handleDeleteWorkingGroup}
|
||||
onSaveBudget={handleSaveBudget}
|
||||
onDeleteBudget={handleDeleteBudget}
|
||||
onDeleteExpense={handleDeleteExpense}
|
||||
onDeleteBudget={handleDeleteBudget} onDeleteExpense={handleDeleteExpense}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
@@ -1710,7 +1974,7 @@ export function DashboardShell({
|
||||
) : desktopSection === "periods" ? (
|
||||
<Stack direction={{ xs: "column", xl: "row" }} gap={3} alignItems="flex-start">
|
||||
{canManagePeriods ? (
|
||||
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 430 }, flexShrink: 0 }}>
|
||||
<Card sx={{ ...islandCardSx, width: { xs: "100%", xl: 560 }, flexShrink: 0 }}>
|
||||
<CardContent sx={{ p: 3 }}>{periodManagementPanel}</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
Reference in New Issue
Block a user