Files
paperlessmanager/paperless-frontend/src/layouts/AppLayout.tsx
T
bjoernpoettker 4d05a94681 feat: implement two-step Freigabe→Zahlung workflow
Adds a payment step after document approval: PM_Freigabe approves
(Field 15 = "freigegeben"), then PM_Zahlung can mark as paid (Field 16).

- Backend: VIEW_ZAHLUNG permission mapped to PM_Zahlung OIDC group
- Backend: ZahlungModule with endpoints to list documents by filter
  (ausstehend/freigegeben/alle), set Field 16, fetch options from Paperless
- Backend: setZahlung() throws ForbiddenException if Field 15 ≠ "freigegeben"
- Frontend: /zahlung route with 3-way filter, two status columns (Freigabe + Zahlung)
- Frontend: "Zahlung verbuchen" button disabled with tooltip for non-approved docs
- Frontend: Zahlung menu item with EuroOutlined icon

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 21:29:50 +02:00

268 lines
9.5 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge } from 'antd';
import {
InboxOutlined,
FileTextOutlined,
MailOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
EditOutlined,
SunOutlined,
MoonOutlined,
AppstoreOutlined,
GlobalOutlined,
CheckCircleOutlined,
EuroOutlined,
} from '@ant-design/icons';
import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext';
import { Permission } from '../auth/permissions';
import { statsApi, type StatsCounts } from '../api/stats';
const { Sider, Content } = Layout;
const { Text } = Typography;
type MenuItemDef = {
key: string;
icon: React.ReactNode;
label: string;
permission?: Permission;
countKey?: keyof StatsCounts;
externalUrl?: string;
};
const allMenuItems: MenuItemDef[] = [
{ key: '/dashboard', icon: <AppstoreOutlined />, label: 'Dashboard' },
{ key: '/inbox', icon: <InboxOutlined />, label: 'Eingangsbox', permission: Permission.VIEW_SCANNER, countKey: 'inbox' },
{ key: '/posteingang', icon: <FileTextOutlined />, label: 'Posteingang', permission: Permission.VIEW_INBOX, countKey: 'posteingang' },
{ key: '/manuell', icon: <EditOutlined />, label: 'Manuell bearbeiten', permission: Permission.PROCESS_MANUALLY, countKey: 'manuell' },
{ key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' },
{ key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' },
{ key: '/freigabe', icon: <CheckCircleOutlined />, label: 'Freigabe', permission: Permission.VIEW_FREIGABE },
{ key: '/zahlung', icon: <EuroOutlined />, label: 'Zahlung', permission: Permission.VIEW_ZAHLUNG },
{ key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS },
];
export default function AppLayout() {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout, hasPermission, isAuthenticated } = useAuth();
const { token: themeToken } = theme.useToken();
const { isDark, toggleTheme } = useTheme();
const [counts, setCounts] = useState<StatsCounts | null>(null);
useEffect(() => {
if (!isAuthenticated) return;
const fetchCounts = async () => {
try {
const data = await statsApi.getCounts();
setCounts(data);
} catch (err) {
console.error('Fehler beim Abrufen der Zählerstände:', err);
}
};
fetchCounts();
const interval = setInterval(fetchCounts, 30000); // 30 Sekunden Polling
return () => clearInterval(interval);
}, [isAuthenticated, location.pathname]); // Update after navigation or auth change
const menuItems = allMenuItems
.filter((item) => !item.permission || hasPermission(item.permission))
.map((item) => ({
...item,
label: (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}>
<span>{item.label}</span>
{item.countKey && counts && counts[item.countKey] > 0 && !collapsed && (
<Badge
count={counts[item.countKey]}
overflowCount={99}
size="small"
color={isDark ? themeToken.colorPrimary : '#1677ff'}
style={{ transform: 'translateY(-2px)' }}
/>
)}
</div>
),
}));
const selectedKey = menuItems
.map((item) => item.key)
.filter((key) => location.pathname === key || location.pathname.startsWith(key + '/'))
.sort((a, b) => b.length - a.length)[0] || (menuItems[0]?.key ?? '/inbox');
const siderStyle = isDark
? {}
: {
background: '#f0f2f7',
borderRight: '1px solid #e2e4ea',
};
const logoColor = isDark ? '#fff' : '#1a1a2e';
const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a';
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
width={240}
trigger={null}
collapsible
collapsed={collapsed}
theme={isDark ? 'dark' : 'light'}
style={{
overflow: 'hidden',
height: '100vh',
position: 'fixed',
left: 0,
top: 0,
bottom: 0,
display: 'flex',
flexDirection: 'column',
...siderStyle,
}}
>
{/* Logo / Collapse-Toggle */}
<button
onClick={() => setCollapsed(!collapsed)}
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
userSelect: 'none',
border: 'none',
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
background: 'transparent',
width: '100%',
padding: 0,
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Text strong style={{ color: logoColor, fontSize: collapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
{collapsed ? 'PM' : 'Paperless'}
</Text>
</button>
{/* Navigation Menu */}
<Menu
theme={isDark ? 'dark' : 'light'}
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => {
const item = allMenuItems.find((i) => i.key === key);
if (item?.externalUrl) {
window.open(item.externalUrl, '_blank', 'noopener,noreferrer');
} else {
navigate(key);
}
}}
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
/>
{/* Bottom Section: User + Theme Toggle */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
padding: collapsed ? '12px 0' : '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
transition: 'padding 0.2s',
}}
>
{/* Theme Toggle */}
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
<button
onClick={toggleTheme}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
color: subtleColor,
justifyContent: collapsed ? 'center' : 'flex-start',
border: 'none',
background: 'transparent',
width: '100%',
transition: 'background 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
{!collapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
</button>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{
items: [
{
key: 'user-settings',
icon: <SettingOutlined />,
label: 'Benutzereinstellungen',
onClick: () => navigate('/user-settings'),
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Abmelden',
onClick: () => logout(),
},
],
}}
placement="topRight"
trigger={['click']}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: collapsed ? '8px 0' : '8px 12px',
borderRadius: 6,
cursor: 'pointer',
justifyContent: collapsed ? 'center' : 'flex-start',
transition: 'all 0.2s',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Avatar size="small" icon={<UserOutlined />} />
{!collapsed && (
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
{user?.profile?.name || 'Benutzer'}
</Text>
)}
</div>
</Dropdown>
</div>
</Sider>
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}