AG Scroll Settings Budget Push und Rechnungsdokumente umsetzen
CI / Build and Deploy (push) Successful in 2m20s
CI / Build and Deploy (push) Successful in 2m20s
This commit is contained in:
@@ -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}
|
||||
|
||||
|
||||
@@ -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