7076eef57b
Build and Push Multi-Platform Images / build-and-push (push) Successful in 39s
- New useIsMobile hook and MobileCardList component - AppLayout: hamburger menu + Drawer navigation on mobile - All list pages (Inbox, Posteingang, Manuell, Mail, Freigabe, Zahlung, TaskLog) show card layout on mobile instead of tables - CSS: responsive modal height, horizontal table scroll, text-size-adjust - Backend: Agrarmonitor polling fixes, Zahlung service improvements, IMAP folder service extended, misc controller fixes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
309 lines
9.5 KiB
TypeScript
309 lines
9.5 KiB
TypeScript
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';
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
|
import MobileCardList from '../components/MobileCardList';
|
|
|
|
const { Title, Text } = Typography;
|
|
const FREIGABE_FIELD_ID = 15;
|
|
const ZAHLUNG_FIELD_ID = 16;
|
|
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
|
|
|
const FILTER_OPTIONS: { value: ZahlungFilter; label: string }[] = [
|
|
{ value: 'ausstehend', label: 'Freigegeben, noch nicht bezahlt' },
|
|
{ value: 'freigegeben', label: 'Alle freigegebenen' },
|
|
{ value: 'alle', label: 'Alle' },
|
|
];
|
|
|
|
export default function ZahlungPage() {
|
|
const isMobile = useIsMobile();
|
|
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',
|
|
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>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
const paginationConfig = {
|
|
current: page,
|
|
pageSize,
|
|
total,
|
|
showSizeChanger: true,
|
|
pageSizeOptions: ['25', '50', '100'],
|
|
onChange: (p: number, ps: number) => {
|
|
setPage(p);
|
|
setPageSize(ps);
|
|
},
|
|
showTotal: (t: number) => `${t} Belege`,
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Zahlung</Title>
|
|
|
|
{isMobile ? (
|
|
<Select
|
|
style={{ width: '100%', marginBottom: 16 }}
|
|
value={filter}
|
|
onChange={(v) => {
|
|
setPage(1);
|
|
setFilter(v);
|
|
}}
|
|
options={FILTER_OPTIONS}
|
|
/>
|
|
) : (
|
|
<Space style={{ marginBottom: 16 }}>
|
|
<Radio.Group
|
|
value={filter}
|
|
onChange={(e) => {
|
|
setPage(1);
|
|
setFilter(e.target.value);
|
|
}}
|
|
optionType="button"
|
|
buttonStyle="solid"
|
|
>
|
|
{FILTER_OPTIONS.map((o) => (
|
|
<Radio.Button key={o.value} value={o.value}>{o.label}</Radio.Button>
|
|
))}
|
|
</Radio.Group>
|
|
</Space>
|
|
)}
|
|
|
|
{isMobile ? (
|
|
<MobileCardList<ZahlungDocument>
|
|
dataSource={data}
|
|
rowKey="id"
|
|
loading={loading}
|
|
pagination={paginationConfig}
|
|
emptyText="Keine Belege vorhanden"
|
|
renderCard={(doc) => {
|
|
const freigegeben = istFreigegeben(doc);
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
<Text strong>{doc.title || '—'}</Text>
|
|
<Text type="secondary" style={{ fontSize: 13 }}>Dokumenttyp: {getDocTypeName(doc.document_type)}</Text>
|
|
<Text type="secondary" style={{ fontSize: 13 }}>Absender: {getCorrespondentName(doc.correspondent)}</Text>
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'}
|
|
</Text>
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
|
{renderFreigabeTag(doc)}
|
|
{renderZahlungTag(doc)}
|
|
</div>
|
|
<Button
|
|
icon={<EuroOutlined />}
|
|
type="primary"
|
|
block
|
|
disabled={!freigegeben}
|
|
onClick={() => openModal(doc)}
|
|
>
|
|
Zahlung verbuchen
|
|
</Button>
|
|
{!freigegeben && (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
Beleg muss zuerst freigegeben werden
|
|
</Text>
|
|
)}
|
|
</div>
|
|
);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Table<ZahlungDocument>
|
|
dataSource={data}
|
|
columns={columns}
|
|
rowKey="id"
|
|
loading={loading}
|
|
size="small"
|
|
pagination={paginationConfig}
|
|
/>
|
|
)}
|
|
|
|
<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>
|
|
</>
|
|
);
|
|
}
|