AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s
All checks were successful
CI / Build and Deploy (push) Successful in 2m20s
This commit is contained in:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user