AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
CI / Build and Deploy (push) Successful in 2m20s

This commit is contained in:
jan
2026-05-05 21:57:20 +02:00
parent 99d4f6dd22
commit f87a82e02f
21 changed files with 885 additions and 323 deletions
+143 -117
View File
@@ -32,7 +32,6 @@ import { useEffect, useMemo, useState } from "react";
import { ColorPickerField } from "@/components/dashboard/color-picker-field";
import type { DashboardBudget, DashboardExpense, DashboardViewer, DashboardWorkingGroup } from "@/lib/dashboard-types";
import {
APPROVAL_FLOW,
approvalLabel,
canDeleteExpense,
canDocumentExpense,
@@ -48,6 +47,7 @@ type BudgetColumnProps = {
viewer: DashboardViewer;
busy: boolean;
approvalThreshold: number;
requiredApprovalTypes: ("CHAIR_A" | "CHAIR_B" | "FINANCE")[];
onApprove: (expenseId: string, approvalType: "CHAIR_A" | "CHAIR_B" | "FINANCE") => Promise<void>;
onMarkPaid: (expenseId: string) => Promise<void>;
onDocument: (expenseId: string, proofUrl?: string) => Promise<void>;
@@ -140,6 +140,7 @@ export function BudgetColumn({
viewer,
busy,
approvalThreshold,
requiredApprovalTypes,
onApprove,
onMarkPaid,
onDocument,
@@ -156,8 +157,7 @@ export function BudgetColumn({
const [editingBudgetId, setEditingBudgetId] = useState<string | null>(null);
const [isEditingGroup, setIsEditingGroup] = useState(false);
const [groupDraftName, setGroupDraftName] = useState(group.name);
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, File | null>>({});
const [invoiceDateDrafts, setInvoiceDateDrafts] = useState<Record<string, string>>({});
const [proofFileDrafts, setProofFileDrafts] = useState<Record<string, { file: File; invoiceDate: string }[]>>({});
const [expandedRecurringExpenses, setExpandedRecurringExpenses] = useState<Record<string, boolean>>({});
const budgetCardWidth = 352;
@@ -223,6 +223,34 @@ export function BudgetColumn({
}));
}
function addProofFiles(expenseId: string, files: FileList | null) {
if (!files || files.length === 0) {
return;
}
const nextFiles = Array.from(files).map((file) => ({ file, invoiceDate: "" }));
setProofFileDrafts((current) => ({
...current,
[expenseId]: [...(current[expenseId] ?? []), ...nextFiles]
}));
}
function updateProofInvoiceDate(expenseId: string, index: number, invoiceDate: string) {
setProofFileDrafts((current) => ({
...current,
[expenseId]: (current[expenseId] ?? []).map((entry, entryIndex) =>
entryIndex === index ? { ...entry, invoiceDate } : entry
)
}));
}
function removeProofDraft(expenseId: string, index: number) {
setProofFileDrafts((current) => ({
...current,
[expenseId]: (current[expenseId] ?? []).filter((_, entryIndex) => entryIndex !== index)
}));
}
return (
<Card
sx={{
@@ -231,7 +259,8 @@ export function BudgetColumn({
maxWidth: "none",
flexShrink: 0,
backgroundColor: alpha(theme.palette.background.paper, isDark ? 0.94 : 0.98),
backgroundImage: "none"
backgroundImage: "none",
touchAction: "pan-y"
}}
>
<CardContent sx={{ p: 3 }}>
@@ -384,20 +413,16 @@ export function BudgetColumn({
</Box>
) : null}
<Box
sx={{
display: "flex",
gap: 2,
overflowX: { xs: "auto", md: "visible" },
overflowY: "hidden",
pb: { xs: 1.5, md: 0 },
alignItems: "stretch",
scrollSnapType: { xs: "x proximity", md: "none" },
scrollbarGutter: { xs: "stable both-edges", md: "auto" },
overscrollBehaviorX: "contain",
width: { md: desktopBudgetListWidth },
minWidth: { md: desktopBudgetListWidth }
}}
<Box
sx={{
display: "flex",
gap: 2,
overflow: "visible",
pb: 0,
alignItems: "stretch",
width: { md: desktopBudgetListWidth },
minWidth: { md: desktopBudgetListWidth }
}}
>
{group.budgets.map((budget) => {
const draft = getDraft(budget);
@@ -644,10 +669,10 @@ export function BudgetColumn({
) : null}
{budget.expenses.map((expense) => {
const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType);
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes)
: [];
const doneApprovalTypes = expense.approvals.map((approval) => approval.approvalType);
const availableApprovals = requiresManualApproval(expense.amount, approvalThreshold)
? getAvailableApprovalTypes(viewer.approvalPermissions, doneApprovalTypes, requiredApprovalTypes)
: [];
const isRecurringSeries = expense.recurrence === "MONTHLY";
const isRecurringExpanded = expandedRecurringExpenses[expense.id] ?? false;
const canUploadProof = expense.creator.id === viewer.id || canDocumentExpense(viewer.role);
@@ -655,15 +680,16 @@ export function BudgetColumn({
return (
<Box
key={expense.id}
sx={{
p: 2.25,
borderRadius: "18px",
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
backgroundColor:
expense.approvalStatus === "APPROVED"
? alpha(budget.colorCode, isDark ? 0.16 : 0.08)
: alpha(budget.colorCode, isDark ? 0.1 : 0.04)
}}
sx={{
p: 2.25,
borderRadius: "18px",
border: `1px solid ${alpha(budget.colorCode, 0.18)}`,
backgroundColor:
expense.approvalStatus === "APPROVED"
? alpha(budget.colorCode, isDark ? 0.16 : 0.08)
: alpha(budget.colorCode, isDark ? 0.1 : 0.04),
touchAction: "pan-y"
}}
>
<Stack spacing={1.4}>
<Stack spacing={1}>
@@ -753,7 +779,7 @@ export function BudgetColumn({
{requiresManualApproval(expense.amount, approvalThreshold) ? (
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{APPROVAL_FLOW.map((approvalType) => {
{requiredApprovalTypes.map((approvalType) => {
const matchingApproval = expense.approvals.find(
(approval) => approval.approvalType === approvalType
);
@@ -776,25 +802,27 @@ export function BudgetColumn({
</Stack>
) : null}
{expense.proofUrl ? (
<Stack spacing={0.4}>
<Link
href={expense.proofUrl}
target="_blank"
rel="noreferrer"
underline="hover"
variant="body2"
sx={{ overflowWrap: "anywhere" }}
>
{"Rechnungsdokument \u00f6ffnen"}
</Link>
{expense.invoiceDate ? (
<Typography variant="caption" color="text.secondary">
Rechnung vom {dateFormatter.format(new Date(expense.invoiceDate))}
</Typography>
) : null}
</Stack>
) : null}
{expense.documents.length > 0 ? (
<Stack spacing={0.5}>
{expense.documents.map((document, documentIndex) => (
<Box key={document.id}>
<Link
href={document.proofUrl}
target="_blank"
rel="noreferrer"
underline="hover"
variant="body2"
sx={{ overflowWrap: "anywhere" }}
>
{`Rechnung ${documentIndex + 1}: ${document.storedFileName}`}
</Link>
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>
Rechnung vom {dateFormatter.format(new Date(document.invoiceDate))}
</Typography>
</Box>
))}
</Stack>
) : null}
<Stack direction="row" gap={1} useFlexGap flexWrap="wrap">
{availableApprovals.map((approvalType) => (
@@ -869,28 +897,10 @@ export function BudgetColumn({
) : null}
</Stack>
{!expense.paidAt &&
expense.approvalStatus === "APPROVED" &&
!expense.proofUrl &&
canUploadProof ? (
<Stack spacing={1} sx={{ width: "100%", maxWidth: 420 }}>
<TextField
label="Rechnungsdatum"
type="date"
value={invoiceDateDrafts[expense.id] ?? ""}
onChange={(event) =>
setInvoiceDateDrafts((current) => ({
...current,
[expense.id]: event.target.value
}))
}
InputLabelProps={{ shrink: true }}
size="small"
required
fullWidth
/>
<Stack direction="row" spacing={1}>
<Button
{expense.approvalStatus === "APPROVED" && canUploadProof ? (
<Stack spacing={1} sx={{ width: "100%", maxWidth: 420 }}>
<Stack direction="row" spacing={1}>
<Button
component="label"
size="small"
variant="outlined"
@@ -900,18 +910,14 @@ export function BudgetColumn({
sx={{ minWidth: 0 }}
>
Datei
<input
hidden
type="file"
accept="image/*,application/pdf"
onChange={(event) =>
setProofFileDrafts((current) => ({
...current,
[expense.id]: event.target.files?.[0] ?? null
}))
}
/>
</Button>
<input
hidden
type="file"
multiple
accept="image/*,application/pdf"
onChange={(event) => addProofFiles(expense.id, event.target.files)}
/>
</Button>
<Button
component="label"
size="small"
@@ -924,28 +930,42 @@ export function BudgetColumn({
Kamera
<input
hidden
type="file"
accept="image/*"
capture="environment"
onChange={(event) =>
setProofFileDrafts((current) => ({
...current,
[expense.id]: event.target.files?.[0] ?? null
}))
}
/>
</Button>
</Stack>
{proofFileDrafts[expense.id]?.name ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: "block", overflowWrap: "anywhere" }}
>
{proofFileDrafts[expense.id]?.name}
</Typography>
) : null}
<Button
type="file"
accept="image/*"
capture="environment"
onChange={(event) => addProofFiles(expense.id, event.target.files)}
/>
</Button>
</Stack>
{(proofFileDrafts[expense.id] ?? []).map((entry, entryIndex) => (
<Stack key={`${entry.file.name}-${entryIndex}`} spacing={0.7}>
<Typography variant="caption" color="text.secondary" sx={{ overflowWrap: "anywhere" }}>
{entry.file.name}
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={1}>
<TextField
label="Rechnungsdatum"
type="date"
value={entry.invoiceDate}
onChange={(event) => updateProofInvoiceDate(expense.id, entryIndex, event.target.value)}
InputLabelProps={{ shrink: true }}
size="small"
required
fullWidth
/>
<Button
type="button"
size="small"
variant="text"
color="error"
onClick={() => removeProofDraft(expense.id, entryIndex)}
>
Entfernen
</Button>
</Stack>
</Stack>
))}
<Button
size="medium"
variant="contained"
color="success"
@@ -960,18 +980,24 @@ export function BudgetColumn({
textAlign: "center"
}}
onClick={async () => {
const proofFile = proofFileDrafts[expense.id];
const invoiceDate = invoiceDateDrafts[expense.id] ?? "";
const proofDrafts = proofFileDrafts[expense.id] ?? [];
if (!proofFile || !invoiceDate) {
return;
}
if (proofDrafts.length === 0 || proofDrafts.some((entry) => !entry.invoiceDate)) {
return;
}
await onUploadProof(expense.id, proofFile, invoiceDate);
}}
>
Rechnung abgeben und bezahlt setzen
</Button>
for (const entry of proofDrafts) {
await onUploadProof(expense.id, entry.file, entry.invoiceDate);
}
setProofFileDrafts((current) => ({
...current,
[expense.id]: []
}));
}}
>
{expense.paidAt ? "Rechnung nachreichen" : "Rechnung abgeben und bezahlt setzen"}
</Button>
</Stack>
) : null}
+167 -82
View File
@@ -8,6 +8,7 @@ import KeyRoundedIcon from "@mui/icons-material/KeyRounded";
import LogoutRoundedIcon from "@mui/icons-material/LogoutRounded";
import NotificationsActiveRoundedIcon from "@mui/icons-material/NotificationsActiveRounded";
import SavingsRoundedIcon from "@mui/icons-material/SavingsRounded";
import SettingsRoundedIcon from "@mui/icons-material/SettingsRounded";
import UploadFileRoundedIcon from "@mui/icons-material/UploadFileRounded";
import VerifiedRoundedIcon from "@mui/icons-material/VerifiedRounded";
import WalletRoundedIcon from "@mui/icons-material/WalletRounded";
@@ -17,8 +18,15 @@ import {
Button,
Card,
CardContent,
Checkbox,
Chip,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Stack,
Tab,
@@ -39,6 +47,7 @@ import type {
DashboardAccountingPeriod,
DashboardAuditLog,
DashboardManagedUser,
DashboardSettings,
DashboardViewer,
DashboardWorkingGroup
} from "@/lib/dashboard-types";
@@ -58,7 +67,7 @@ type DashboardShellProps = {
auditLogs: DashboardAuditLog[];
accountingPeriods: DashboardAccountingPeriod[];
currentPeriodId: string;
approvalThreshold: number;
settings: DashboardSettings;
};
type ExpenseFormState = {
@@ -115,6 +124,11 @@ type PeriodEditFormState = {
endsAt: string;
};
type OrgaSettingsDraft = {
requiredApprovalTypes: ApprovalPermissionValue[];
budgetReleaseNotifyTarget: "ALL_GROUP_USERS" | "GROUP_MEMBERS_ONLY";
};
type DashboardMessage = {
type: "success" | "error";
text: string;
@@ -249,7 +263,7 @@ export function DashboardShell({
auditLogs,
accountingPeriods,
currentPeriodId,
approvalThreshold
settings
}: DashboardShellProps) {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@@ -264,6 +278,7 @@ export function DashboardShell({
const canManageAccounts = canManageUsers(viewer.role);
const canManagePeriods = canManageBudgets(viewer.role);
const currentPeriod = accountingPeriods.find((period) => period.id === currentPeriodId) ?? accountingPeriods[0];
const approvalThreshold = settings.approvalThreshold;
const desktopSections = [
{ value: "overview" as const, label: "\u00dcbersicht" },
...(canManagePeriods ? [{ value: "budgetGroups" as const, label: "Budget / AGs" }] : []),
@@ -310,35 +325,25 @@ export function DashboardShell({
});
const [message, setMessage] = useState<DashboardMessage | null>(null);
const [busy, setBusy] = useState(false);
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
const [selectedMobileGroupId, setSelectedMobileGroupId] = useState(
viewer.workingGroupId ?? visibleGroups[0]?.id ?? ""
);
const [backupFile, setBackupFile] = useState<File | null>(null);
const [mobileSection, setMobileSection] = useState<MobileSection>("overview");
const [desktopSection, setDesktopSection] = useState<DesktopSection>("overview");
const [selectedCurrentPeriodId, setSelectedCurrentPeriodId] = useState(currentPeriodId);
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 [managedUsersState, setManagedUsersState] = useState(() => sortManagedUsersList(managedUsers));
const [userDrafts, setUserDrafts] = useState<Record<string, ManagedUserDraft>>({});
const [approvalThresholdDraft, setApprovalThresholdDraft] = useState(approvalThreshold.toFixed(2));
const [isOrgaSettingsOpen, setIsOrgaSettingsOpen] = useState(false);
const [orgaSettingsDraft, setOrgaSettingsDraft] = useState<OrgaSettingsDraft>({
requiredApprovalTypes: settings.requiredApprovalTypes,
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
});
const [periodForm, setPeriodForm] = useState<PeriodFormState>(getSuggestedPeriodDraft(currentPeriod));
const [periodEditForm, setPeriodEditForm] = useState<PeriodEditFormState>(getPeriodEditDraft(currentPeriod));
const [pushStatus, setPushStatus] = useState<"idle" | "enabled" | "blocked" | "unsupported">("idle");
useEffect(() => {
if (visibleGroups.length === 0) {
setSelectedMobileGroupId("");
return;
}
const hasSelectedGroup = visibleGroups.some((group) => group.id === selectedMobileGroupId);
if (!hasSelectedGroup) {
setSelectedMobileGroupId(viewer.workingGroupId ?? visibleGroups[0]?.id ?? "");
}
}, [selectedMobileGroupId, viewer.workingGroupId, visibleGroups]);
useEffect(() => {
useEffect(() => {
setSelectedCurrentPeriodId(currentPeriodId);
setPeriodForm(getSuggestedPeriodDraft(currentPeriod));
}, [currentPeriod, currentPeriodId]);
@@ -473,6 +478,13 @@ export function DashboardShell({
setApprovalThresholdDraft(approvalThreshold.toFixed(2));
}, [approvalThreshold]);
useEffect(() => {
setOrgaSettingsDraft({
requiredApprovalTypes: settings.requiredApprovalTypes,
budgetReleaseNotifyTarget: settings.budgetReleaseNotifyTarget
});
}, [settings.budgetReleaseNotifyTarget, settings.requiredApprovalTypes]);
useEffect(() => {
setManagedUsersState(sortManagedUsersList(managedUsers));
}, [managedUsers]);
@@ -482,11 +494,10 @@ export function DashboardShell({
setEditingUserId(null);
}
}, [editingUserId, managedUsersState]);
const selectedExpenseGroup =
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
const mobileSelectedGroup = visibleGroups.find((group) => group.id === selectedMobileGroupId) ?? visibleGroups[0];
const selectedBudgetWorkingGroup =
const selectedExpenseGroup =
editableExpenseGroups.find((group) => group.id === expenseForm.agId) ?? defaultEditableGroup;
const selectedBudgetOptions = selectedExpenseGroup?.budgets ?? [];
const selectedBudgetWorkingGroup =
visibleGroups.find((group) => group.id === budgetForm.workingGroupId) ?? null;
const selectedBudgetReleaseGroup =
visibleGroups.find((group) => group.id === budgetReleaseForm.workingGroupId) ?? visibleGroups[0] ?? null;
@@ -767,14 +778,14 @@ export function DashboardShell({
method: "POST",
body: formData
})
)) as { proofUrl: string };
)) as { document: { proofUrl: string } };
setMessage({ type: "success", text: "Rechnung wurde abgegeben und die Ausgabe ist jetzt bezahlt." });
startTransition(() => {
router.refresh();
});
return result.proofUrl;
return result.document.proofUrl;
} catch (error) {
const text = error instanceof Error ? error.message : "Beleg konnte nicht hochgeladen werden.";
setMessage({ type: "error", text });
@@ -1059,6 +1070,27 @@ export function DashboardShell({
}, `Freigabe-Schwelle wurde auf ${nextThreshold.toFixed(2)} EUR gesetzt.`);
}
async function handleSaveOrgaSettings() {
if (orgaSettingsDraft.requiredApprovalTypes.length === 0) {
setMessage({ type: "error", text: "Bitte mindestens eine Freigaberolle ausw\u00e4hlen." });
return;
}
await runAction(async () => {
await parseResponse(
await fetch("/api/settings", {
method: "PATCH",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(orgaSettingsDraft)
})
);
setIsOrgaSettingsOpen(false);
}, "Zust\u00e4ndigkeiten und Benachrichtigungen wurden gespeichert.");
}
async function handleEnablePushNotifications() {
if (!("serviceWorker" in navigator) || !("PushManager" in window) || !("Notification" in window)) {
setPushStatus("unsupported");
@@ -2345,46 +2377,30 @@ export function DashboardShell({
const overviewContent = (
<Stack spacing={2.5}>
{isCompactLayout && visibleGroups.length > 1 ? (
<Card>
<CardContent sx={{ p: 2.5 }}>
<Stack spacing={1.5}>
<Box>
<Typography variant="h3" sx={{ fontSize: "1.15rem" }}>
AG auswählen
</Typography>
<Typography color="text.secondary">
Mobil zeigen wir jeweils eine AG auf einmal, damit die Budgetkarten sauber lesbar bleiben.
</Typography>
</Box>
<TextField
select
label="Sichtbare AG"
value={selectedMobileGroupId}
onChange={(event) => setSelectedMobileGroupId(event.target.value)}
fullWidth
>
{visibleGroups.map((group) => (
<MenuItem key={group.id} value={group.id}>
{group.name}
</MenuItem>
))}
</TextField>
</Stack>
</CardContent>
</Card>
) : null}
{isCompactLayout ? (
<Stack direction="column" gap={2} sx={{ width: "100%", minWidth: 0 }}>
{(mobileSelectedGroup ? [mobileSelectedGroup] : []).map((group) => (
<Box key={group.id} sx={{ width: "100%", minWidth: 0 }}>
<Box
sx={{
width: "100%",
minWidth: 0,
maxWidth: "100%",
overflowX: "auto",
overflowY: "hidden",
pb: 2,
scrollbarGutter: "stable both-edges",
overscrollBehaviorX: "contain",
touchAction: "pan-x pan-y"
}}
>
<Stack direction="row" gap={2} sx={{ width: "max-content", minWidth: "100%", alignItems: "stretch" }}>
{visibleGroups.map((group) => (
<Box key={group.id} sx={{ width: "min(88vw, 456px)", flex: "0 0 auto", scrollSnapAlign: "start" }}>
<BudgetColumn
group={group}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
onApprove={handleApprove}
busy={busy}
approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes}
onApprove={handleApprove}
onMarkPaid={handleMarkPaid}
onDocument={handleDocument}
onUploadProof={handleUploadProof}
@@ -2395,8 +2411,9 @@ export function DashboardShell({
onDeleteExpense={handleDeleteExpense}
/>
</Box>
))}
</Stack>
))}
</Stack>
</Box>
) : (
<Box
sx={{
@@ -2404,11 +2421,12 @@ export function DashboardShell({
minWidth: 0,
maxWidth: "100%",
overflowX: "auto",
overflowY: "hidden",
pb: 2,
scrollbarGutter: "stable both-edges",
overscrollBehaviorX: "contain"
}}
overflowY: "hidden",
pb: 2,
scrollbarGutter: "stable both-edges",
overscrollBehaviorX: "contain",
touchAction: "pan-x pan-y"
}}
>
<Stack
direction="row"
@@ -2425,9 +2443,10 @@ export function DashboardShell({
<BudgetColumn
group={group}
viewer={viewer}
busy={busy}
approvalThreshold={approvalThreshold}
onApprove={handleApprove}
busy={busy}
approvalThreshold={approvalThreshold}
requiredApprovalTypes={settings.requiredApprovalTypes}
onApprove={handleApprove}
onMarkPaid={handleMarkPaid}
onDocument={handleDocument}
onUploadProof={handleUploadProof}
@@ -2502,8 +2521,8 @@ export function DashboardShell({
</Typography>
</Box>
<Stack direction={{ xs: "column", sm: "row" }} gap={1.2} alignItems={{ sm: "center" }}>
{viewer.approvalPermissions.length > 0 ? (
<Button
{viewer.approvalPermissions.length > 0 ? (
<Button
type="button"
size="small"
variant={pushStatus === "enabled" ? "contained" : "outlined"}
@@ -2513,9 +2532,18 @@ export function DashboardShell({
onClick={handleEnablePushNotifications}
>
{pushStatus === "enabled" ? "Web Push aktiv" : "Freigabe-Push"}
</Button>
) : null}
<Chip
</Button>
) : null}
{viewer.role === "ORGA" ? (
<IconButton
aria-label="Zuständigkeiten und Benachrichtigungen"
sx={{ border: `1px solid ${alpha("#FFFFFF", 0.28)}`, color: "white" }}
onClick={() => setIsOrgaSettingsOpen(true)}
>
<SettingsRoundedIcon />
</IconButton>
) : null}
<Chip
label={`${viewer.username} - ${roleLabel(viewer.role)}`}
sx={{ bgcolor: alpha("#FFFFFF", 0.14), color: "white", fontWeight: 700, maxWidth: "100%" }}
/>
@@ -2567,9 +2595,66 @@ export function DashboardShell({
</CardContent>
</Card>
</Container>
</Box>
</Box>
<Container maxWidth={false} sx={{ maxWidth: 1640 }}>
<Dialog open={isOrgaSettingsOpen} onClose={() => setIsOrgaSettingsOpen(false)} fullWidth maxWidth="sm">
<DialogTitle>Zuständigkeiten & Benachrichtigungen</DialogTitle>
<DialogContent>
<Stack spacing={2.5} sx={{ pt: 1 }}>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
Freigaberollen
</Typography>
<Typography variant="body2" color="text.secondary">
Diese Rollen müssen neue schwellenpflichtige Ausgaben bestätigen.
</Typography>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{APPROVAL_FLOW.map((approvalType) => (
<FormControlLabel
key={approvalType}
control={
<Checkbox
checked={orgaSettingsDraft.requiredApprovalTypes.includes(approvalType)}
onChange={() =>
setOrgaSettingsDraft((current) => ({
...current,
requiredApprovalTypes: toggleApprovalPermission(current.requiredApprovalTypes, approvalType)
}))
}
/>
}
label={approvalLabel(approvalType)}
/>
))}
</Stack>
</Box>
<TextField
select
label="Budgetfreigabe-Push"
value={orgaSettingsDraft.budgetReleaseNotifyTarget}
onChange={(event) =>
setOrgaSettingsDraft((current) => ({
...current,
budgetReleaseNotifyTarget: event.target.value as OrgaSettingsDraft["budgetReleaseNotifyTarget"]
}))
}
fullWidth
helperText="Wer informiert wird, wenn ein Budget an eine AG übergeben wird."
>
<MenuItem value="ALL_GROUP_USERS">Alle Nutzer der AG</MenuItem>
<MenuItem value="GROUP_MEMBERS_ONLY">Nur AG-Mitglieder</MenuItem>
</TextField>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={() => setIsOrgaSettingsOpen(false)}>Abbrechen</Button>
<Button variant="contained" disabled={busy} onClick={handleSaveOrgaSettings}>
Einstellungen speichern
</Button>
</DialogActions>
</Dialog>
<Container maxWidth={false} sx={{ maxWidth: 1640 }}>
<Stack spacing={3} px={2}>
{message ? <Alert severity={message.type}>{message.text}</Alert> : null}
{periodOverviewCard}