feat: implement Freigabesystem for payment approval workflow
Adds a dedicated approval view for PM_Freigabe users to release documents for payment by setting Paperless custom field 15 to a predefined value. - Backend: VIEW_FREIGABE permission mapped to PM_Freigabe OIDC group - Backend: FreigabeErforderlich flag on DocumentType entity (auto-migrated) - Backend: FreigabeModule with endpoints to list documents, fetch field options dynamically from Paperless, and set the approval custom field - Frontend: /freigabe route with filter (default: nicht freigegeben), paginated table, and modal to select approval value - Frontend: Settings checkbox to mark document types as requiring approval - Frontend: Freigabe menu item visible only to PM_Freigabe/PM_Admin users Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Typography, Tag, Button, Modal, Select, message, Space, Radio,
|
||||
} from 'antd';
|
||||
import { CheckCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe';
|
||||
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
||||
|
||||
const { Title } = Typography;
|
||||
const FREIGABE_FIELD_ID = 15;
|
||||
|
||||
export default function FreigabePage() {
|
||||
const [data, setData] = useState<FreigabeDocument[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [nurNichtFreigegeben, setNurNichtFreigegeben] = useState(true);
|
||||
|
||||
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
|
||||
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
|
||||
const [freigabeOptions, setFreigabeOptions] = useState<FreigabeOption[]>([]);
|
||||
|
||||
const [selectedDoc, setSelectedDoc] = useState<FreigabeDocument | 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(),
|
||||
freigabeApi.getOptions(),
|
||||
]).then(([dts, corrs, opts]) => {
|
||||
setDocTypes(dts);
|
||||
setCorrespondents(corrs);
|
||||
setFreigabeOptions(opts);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await freigabeApi.getDocuments(page, pageSize, nurNichtFreigegeben);
|
||||
setData(result.results ?? []);
|
||||
setTotal(result.count ?? 0);
|
||||
} catch {
|
||||
message.error('Fehler beim Laden der Belege');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, nurNichtFreigegeben]);
|
||||
|
||||
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 getFreigabeValue = (doc: FreigabeDocument) => {
|
||||
const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID);
|
||||
if (!cf || cf.value === null || cf.value === '' || cf.value === undefined) {
|
||||
return <Tag color="warning">Nicht gesetzt</Tag>;
|
||||
}
|
||||
const opt = freigabeOptions.find((o) => String(o.id) === String(cf.value));
|
||||
return <Tag color="success">{opt?.label ?? String(cf.value)}</Tag>;
|
||||
};
|
||||
|
||||
const openModal = (doc: FreigabeDocument) => {
|
||||
const cf = doc.custom_fields?.find((f) => f.field === FREIGABE_FIELD_ID);
|
||||
setSelectedDoc(doc);
|
||||
setSelectedValue(cf?.value ?? null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFreigabe = async () => {
|
||||
if (!selectedDoc) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await freigabeApi.setFreigabe(selectedDoc.id, selectedValue);
|
||||
message.success('Freigabe gesetzt');
|
||||
setModalOpen(false);
|
||||
setSelectedDoc(null);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('Fehler beim Speichern der Freigabe');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<FreigabeDocument> = [
|
||||
{
|
||||
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: 140,
|
||||
render: (_, doc) => getFreigabeValue(doc),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 130,
|
||||
render: (_, doc) => (
|
||||
<Button
|
||||
icon={<CheckCircleOutlined />}
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => openModal(doc)}
|
||||
>
|
||||
Freigabe setzen
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={nurNichtFreigegeben}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setNurNichtFreigegeben(e.target.value);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value={true}>Nicht freigegeben</Radio.Button>
|
||||
<Radio.Button value={false}>Alle</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
|
||||
<Table<FreigabeDocument>
|
||||
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="Freigabe setzen"
|
||||
open={modalOpen}
|
||||
onOk={handleFreigabe}
|
||||
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="Freigabe-Status wählen"
|
||||
allowClear
|
||||
value={selectedValue ?? undefined}
|
||||
onChange={(v) => setSelectedValue(v ?? null)}
|
||||
options={freigabeOptions.map((o) => ({ value: o.id, label: o.label }))}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user