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>
This commit is contained in:
@@ -21,6 +21,7 @@ const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage'));
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const FreigabePage = lazy(() => import('./pages/FreigabePage'));
|
||||
const ZahlungPage = lazy(() => import('./pages/ZahlungPage'));
|
||||
import { Permission } from './auth/permissions';
|
||||
|
||||
function UnauthorizedPage() {
|
||||
@@ -133,6 +134,7 @@ function ThemedApp() {
|
||||
<Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} />
|
||||
<Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} />
|
||||
<Route path="/freigabe" element={<PermissionRoute permission={Permission.VIEW_FREIGABE}><FreigabePage /></PermissionRoute>} />
|
||||
<Route path="/zahlung" element={<PermissionRoute permission={Permission.VIEW_ZAHLUNG}><ZahlungPage /></PermissionRoute>} />
|
||||
<Route path="/user-settings" element={<UserSettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import api from './client';
|
||||
|
||||
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
|
||||
|
||||
export interface ZahlungDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
created: string;
|
||||
created_date: string;
|
||||
correspondent: number | null;
|
||||
document_type: number | null;
|
||||
archive_serial_number: number | null;
|
||||
tags: number[];
|
||||
custom_fields: { field: number; value: any }[];
|
||||
}
|
||||
|
||||
export interface ZahlungOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ZahlungResult {
|
||||
count: number;
|
||||
results: ZahlungDocument[];
|
||||
}
|
||||
|
||||
export const zahlungApi = {
|
||||
getDocuments: (page = 1, pageSize = 25, filter: ZahlungFilter = 'ausstehend') =>
|
||||
api
|
||||
.get<ZahlungResult>('/api/zahlung/documents', { params: { page, pageSize, filter } })
|
||||
.then((r) => r.data),
|
||||
|
||||
setZahlung: (docId: number, value: string | null) =>
|
||||
api
|
||||
.put<{ success: boolean }>(`/api/zahlung/documents/${docId}/zahlung`, { value })
|
||||
.then((r) => r.data),
|
||||
|
||||
getOptions: () =>
|
||||
api.get<ZahlungOption[]>('/api/zahlung/options').then((r) => r.data),
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export const Permission = {
|
||||
VIEW_SCANNER: 'VIEW_SCANNER',
|
||||
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
||||
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
||||
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
|
||||
} as const;
|
||||
|
||||
export type Permission = typeof Permission[keyof typeof Permission];
|
||||
@@ -26,6 +27,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
|
||||
permissions.add(Permission.VIEW_SCANNER);
|
||||
permissions.add(Permission.MANAGE_SETTINGS);
|
||||
permissions.add(Permission.VIEW_FREIGABE);
|
||||
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
@@ -44,6 +46,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
|
||||
if (groups.includes('PM_Freigabe')) {
|
||||
permissions.add(Permission.VIEW_FREIGABE);
|
||||
}
|
||||
if (groups.includes('PM_Zahlung')) {
|
||||
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
}
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
AppstoreOutlined,
|
||||
GlobalOutlined,
|
||||
CheckCircleOutlined,
|
||||
EuroOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useTheme } from '../theme/ThemeContext';
|
||||
@@ -40,6 +41,7 @@ const allMenuItems: MenuItemDef[] = [
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Typography, Tag, Button, Modal, Select, message, Space, Radio, Tooltip } from 'antd';
|
||||
import { EuroOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung';
|
||||
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
||||
|
||||
const { Title } = Typography;
|
||||
const FREIGABE_FIELD_ID = 15;
|
||||
const ZAHLUNG_FIELD_ID = 16;
|
||||
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
||||
|
||||
export default function ZahlungPage() {
|
||||
const [data, setData] = useState<ZahlungDocument[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [filter, setFilter] = useState<ZahlungFilter>('ausstehend');
|
||||
|
||||
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
|
||||
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
|
||||
const [zahlungOptions, setZahlungOptions] = useState<ZahlungOption[]>([]);
|
||||
|
||||
const [selectedDoc, setSelectedDoc] = useState<ZahlungDocument | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
paperlessApi.getDocumentTypes(),
|
||||
paperlessApi.getCorrespondents(),
|
||||
zahlungApi.getOptions(),
|
||||
]).then(([dts, corrs, opts]) => {
|
||||
setDocTypes(dts);
|
||||
setCorrespondents(corrs);
|
||||
setZahlungOptions(opts);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await zahlungApi.getDocuments(page, pageSize, filter);
|
||||
setData(result.results ?? []);
|
||||
setTotal(result.count ?? 0);
|
||||
} catch {
|
||||
message.error('Fehler beim Laden der Belege');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const getDocTypeName = (id: number | null) => {
|
||||
if (!id) return '—';
|
||||
return docTypes.find((d) => d.id === id)?.name ?? String(id);
|
||||
};
|
||||
|
||||
const getCorrespondentName = (id: number | null) => {
|
||||
if (!id) return '—';
|
||||
return correspondents.find((c) => c.id === id)?.name ?? String(id);
|
||||
};
|
||||
|
||||
const toCfString = (value: any): string | null => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'object') return String(value?.id ?? value?.value ?? value?.label ?? '') || null;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getCfValue = (doc: ZahlungDocument, fieldId: number) => {
|
||||
const cf = doc.custom_fields?.find((f) => f.field === fieldId);
|
||||
return toCfString(cf?.value);
|
||||
};
|
||||
|
||||
const renderFreigabeTag = (doc: ZahlungDocument) => {
|
||||
const val = getCfValue(doc, FREIGABE_FIELD_ID);
|
||||
if (!val) return <Tag color="default">Nicht gesetzt</Tag>;
|
||||
if (val === FREIGABE_WERT_FREIGEGEBEN) return <Tag color="success">Freigegeben</Tag>;
|
||||
return <Tag color="warning">{val}</Tag>;
|
||||
};
|
||||
|
||||
const renderZahlungTag = (doc: ZahlungDocument) => {
|
||||
const val = getCfValue(doc, ZAHLUNG_FIELD_ID);
|
||||
if (!val) return <Tag color="default">Nicht gesetzt</Tag>;
|
||||
const opt = zahlungOptions.find((o) => o.id === val);
|
||||
return <Tag color="blue">{opt?.label ?? val}</Tag>;
|
||||
};
|
||||
|
||||
const istFreigegeben = (doc: ZahlungDocument) =>
|
||||
getCfValue(doc, FREIGABE_FIELD_ID) === FREIGABE_WERT_FREIGEGEBEN;
|
||||
|
||||
const openModal = (doc: ZahlungDocument) => {
|
||||
setSelectedDoc(doc);
|
||||
setSelectedValue(getCfValue(doc, ZAHLUNG_FIELD_ID));
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleZahlung = async () => {
|
||||
if (!selectedDoc) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await zahlungApi.setZahlung(selectedDoc.id, selectedValue);
|
||||
message.success('Zahlung gesetzt');
|
||||
setModalOpen(false);
|
||||
setSelectedDoc(null);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('Fehler beim Speichern der Zahlung');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ZahlungDocument> = [
|
||||
{
|
||||
title: 'Dokumenttyp',
|
||||
dataIndex: 'document_type',
|
||||
key: 'doctype',
|
||||
render: getDocTypeName,
|
||||
},
|
||||
{
|
||||
title: 'Titel',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Erstellt',
|
||||
dataIndex: 'created_date',
|
||||
key: 'created',
|
||||
width: 110,
|
||||
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
||||
},
|
||||
{
|
||||
title: 'Absender',
|
||||
dataIndex: 'correspondent',
|
||||
key: 'correspondent',
|
||||
render: getCorrespondentName,
|
||||
},
|
||||
{
|
||||
title: 'Freigabe',
|
||||
key: 'freigabe',
|
||||
width: 130,
|
||||
render: (_, doc) => renderFreigabeTag(doc),
|
||||
},
|
||||
{
|
||||
title: 'Zahlung',
|
||||
key: 'zahlung',
|
||||
width: 130,
|
||||
render: (_, doc) => renderZahlungTag(doc),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_, doc) => {
|
||||
const freigegeben = istFreigegeben(doc);
|
||||
return (
|
||||
<Tooltip title={!freigegeben ? 'Beleg muss zuerst freigegeben werden' : undefined}>
|
||||
<Button
|
||||
icon={<EuroOutlined />}
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!freigegeben}
|
||||
onClick={() => openModal(doc)}
|
||||
>
|
||||
Zahlung verbuchen
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Zahlung</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setFilter(e.target.value);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="ausstehend">Freigegeben, noch nicht bezahlt</Radio.Button>
|
||||
<Radio.Button value="freigegeben">Alle freigegebenen</Radio.Button>
|
||||
<Radio.Button value="alle">Alle</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
|
||||
<Table<ZahlungDocument>
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['25', '50', '100'],
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
showTotal: (t) => `${t} Belege`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Zahlung verbuchen"
|
||||
open={modalOpen}
|
||||
onOk={handleZahlung}
|
||||
onCancel={() => { setModalOpen(false); setSelectedDoc(null); }}
|
||||
okText="Speichern"
|
||||
cancelText="Abbrechen"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
<strong>{selectedDoc?.title}</strong>
|
||||
</p>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Zahlungsstatus wählen"
|
||||
allowClear
|
||||
value={selectedValue ?? undefined}
|
||||
onChange={(v) => setSelectedValue(v ?? null)}
|
||||
options={zahlungOptions.map((o) => ({ value: o.id, label: o.label }))}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user