AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
All checks were successful
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

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}