Compare commits
16 Commits
b77a283ab2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 32ba772e4e | |||
| 095cc4bb02 | |||
| 7b2a79be2a | |||
| 1c70473cef | |||
| beaa1be4a5 | |||
| 57c8964384 | |||
| 8390d03869 | |||
| 3cbb64686a | |||
| 7076eef57b | |||
| 0765d14d3b | |||
| 8fda248683 | |||
| 156b0401b3 | |||
| e50111a731 | |||
| 9718d6888a | |||
| 66a2cccd20 | |||
| c665451abf |
@@ -14,3 +14,6 @@ dist/
|
|||||||
|
|
||||||
# Docker
|
# Docker
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# Secrets
|
||||||
|
.gitea_token
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ Paperless Manager is a document automation platform that extends [Paperless-NGX]
|
|||||||
|
|
||||||
UI labels and comments are in **German**.
|
UI labels and comments are in **German**.
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
Alle Antworten an den Nutzer, Erklärungen, Commit-Messages, Code-Kommentare und UI-Texte
|
||||||
|
sollen — wo möglich — auf **Deutsch** verfasst werden. Technische Bezeichner (Variablen-,
|
||||||
|
Funktions- und Dateinamen) bleiben unverändert in der bestehenden Konvention.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
|
await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
|
||||||
await this.upsertSetting('agrarmonitor_link_field', '');
|
await this.upsertSetting('agrarmonitor_link_field', '');
|
||||||
await this.upsertSetting('agrarmonitor_tag_manuell', '');
|
await this.upsertSetting('agrarmonitor_tag_manuell', '');
|
||||||
|
await this.upsertSetting('agrarmonitor_import_wartezeit_minuten', '10');
|
||||||
|
await this.upsertSetting('agrarmonitor_notiz_marker', 'Agrarmonitor');
|
||||||
}
|
}
|
||||||
|
|
||||||
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
|
@Cron(process.env['AGRARMONITOR_POLLING_CRON'] || '0 */30 * * * *')
|
||||||
@@ -80,14 +82,27 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
tagHochgeladen: string;
|
tagHochgeladen: string;
|
||||||
linkField: string;
|
linkField: string;
|
||||||
tagManuell: string;
|
tagManuell: string;
|
||||||
|
importWartezeitMinuten: string;
|
||||||
|
notizMarker: string;
|
||||||
}> {
|
}> {
|
||||||
const [fertig, verbucht, hochgeladen, linkField, manuell] =
|
const [
|
||||||
await Promise.all([
|
fertig,
|
||||||
|
verbucht,
|
||||||
|
hochgeladen,
|
||||||
|
linkField,
|
||||||
|
manuell,
|
||||||
|
wartezeit,
|
||||||
|
marker,
|
||||||
|
] = await Promise.all([
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
||||||
|
this.settingRepo.findOneBy({
|
||||||
|
Tag: 'agrarmonitor_import_wartezeit_minuten',
|
||||||
|
}),
|
||||||
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
tagFertig: fertig?.Wert ?? '4',
|
tagFertig: fertig?.Wert ?? '4',
|
||||||
@@ -95,6 +110,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
tagHochgeladen: hochgeladen?.Wert ?? '',
|
tagHochgeladen: hochgeladen?.Wert ?? '',
|
||||||
linkField: linkField?.Wert ?? '',
|
linkField: linkField?.Wert ?? '',
|
||||||
tagManuell: manuell?.Wert ?? '',
|
tagManuell: manuell?.Wert ?? '',
|
||||||
|
importWartezeitMinuten: wartezeit?.Wert ?? '10',
|
||||||
|
notizMarker: marker?.Wert ?? 'Agrarmonitor',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,12 +121,16 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
tagHochgeladen: string,
|
tagHochgeladen: string,
|
||||||
linkField: string,
|
linkField: string,
|
||||||
tagManuell: string,
|
tagManuell: string,
|
||||||
|
importWartezeitMinuten: string,
|
||||||
|
notizMarker: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
tagFertig: string;
|
tagFertig: string;
|
||||||
tagVerbucht: string;
|
tagVerbucht: string;
|
||||||
tagHochgeladen: string;
|
tagHochgeladen: string;
|
||||||
linkField: string;
|
linkField: string;
|
||||||
tagManuell: string;
|
tagManuell: string;
|
||||||
|
importWartezeitMinuten: string;
|
||||||
|
notizMarker: string;
|
||||||
}> {
|
}> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.settingRepo.update(
|
this.settingRepo.update(
|
||||||
@@ -132,8 +153,24 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
{ Tag: 'agrarmonitor_tag_manuell' },
|
{ Tag: 'agrarmonitor_tag_manuell' },
|
||||||
{ Wert: tagManuell },
|
{ Wert: tagManuell },
|
||||||
),
|
),
|
||||||
|
this.settingRepo.update(
|
||||||
|
{ Tag: 'agrarmonitor_import_wartezeit_minuten' },
|
||||||
|
{ Wert: importWartezeitMinuten },
|
||||||
|
),
|
||||||
|
this.settingRepo.update(
|
||||||
|
{ Tag: 'agrarmonitor_notiz_marker' },
|
||||||
|
{ Wert: notizMarker },
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
return { tagFertig, tagVerbucht, tagHochgeladen, linkField, tagManuell };
|
return {
|
||||||
|
tagFertig,
|
||||||
|
tagVerbucht,
|
||||||
|
tagHochgeladen,
|
||||||
|
linkField,
|
||||||
|
tagManuell,
|
||||||
|
importWartezeitMinuten,
|
||||||
|
notizMarker,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async runPolling(): Promise<PollingResult> {
|
async runPolling(): Promise<PollingResult> {
|
||||||
@@ -393,17 +430,26 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
fertigSetting,
|
fertigSetting,
|
||||||
linkFieldSetting,
|
linkFieldSetting,
|
||||||
manuellSetting,
|
manuellSetting,
|
||||||
|
wartezeitSetting,
|
||||||
|
markerSetting,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
||||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
||||||
|
this.settingRepo.findOneBy({
|
||||||
|
Tag: 'agrarmonitor_import_wartezeit_minuten',
|
||||||
|
}),
|
||||||
|
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10);
|
const tagHochgeladenId = parseInt(hochgeladenSetting?.Wert ?? '', 10);
|
||||||
const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
|
const tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
|
||||||
const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10);
|
const linkFieldId = parseInt(linkFieldSetting?.Wert ?? '', 10);
|
||||||
const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10);
|
const tagManuellId = parseInt(manuellSetting?.Wert ?? '', 10);
|
||||||
|
const importWartezeitMinuten =
|
||||||
|
parseInt(wartezeitSetting?.Wert ?? '10', 10) || 10;
|
||||||
|
const notizMarker = (markerSetting?.Wert ?? 'Agrarmonitor').trim();
|
||||||
|
|
||||||
if (isNaN(tagHochgeladenId)) {
|
if (isNaN(tagHochgeladenId)) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
@@ -512,6 +558,40 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Karenzzeit: evtl. hat Agrarmonitor die E-Mail noch nicht importiert.
|
||||||
|
// Ist die Sende-Notiz jünger als die konfigurierte Wartezeit, nicht markieren.
|
||||||
|
const marker = notizMarker.toLowerCase();
|
||||||
|
if (marker) {
|
||||||
|
let notes: Array<{ note: string; created: string }>;
|
||||||
|
try {
|
||||||
|
notes = await this.paperlessService.getNotes(doc.id as number);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Vorsichtig: bei Notiz-Abruf-Fehler NICHT markieren, nächster Lauf prüft erneut
|
||||||
|
this.logger.warn(
|
||||||
|
`${interneBelegnummer}: Notiz-Abruf fehlgeschlagen — nicht markiert: ${err instanceof Error ? err.message : err}`,
|
||||||
|
);
|
||||||
|
result.skipped++;
|
||||||
|
await this.delay(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const newest = notes
|
||||||
|
.filter((n) => (n.note ?? '').toLowerCase().includes(marker))
|
||||||
|
.map((n) => new Date(n.created).getTime())
|
||||||
|
.filter((t) => !isNaN(t))
|
||||||
|
.reduce((max, t) => Math.max(max, t), 0);
|
||||||
|
if (
|
||||||
|
newest > 0 &&
|
||||||
|
Date.now() - newest < importWartezeitMinuten * 60 * 1000
|
||||||
|
) {
|
||||||
|
this.logger.log(
|
||||||
|
`${interneBelegnummer}: Sende-Notiz jünger als ${importWartezeitMinuten} Min — warte auf Import`,
|
||||||
|
);
|
||||||
|
result.skipped++;
|
||||||
|
await this.delay(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen
|
// Weder verbucht noch im Dateieingang → Tags "Manuell bearbeiten" + "Von AM zurück" setzen
|
||||||
if (!isNaN(tagManuellId)) {
|
if (!isNaN(tagManuellId)) {
|
||||||
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export class AgrarmonitorController {
|
|||||||
tagHochgeladen: string;
|
tagHochgeladen: string;
|
||||||
linkField: string;
|
linkField: string;
|
||||||
tagManuell: string;
|
tagManuell: string;
|
||||||
|
importWartezeitMinuten: string;
|
||||||
|
notizMarker: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
return this.pollingService.updatePollingConfig(
|
return this.pollingService.updatePollingConfig(
|
||||||
@@ -50,6 +52,8 @@ export class AgrarmonitorController {
|
|||||||
body.tagHochgeladen,
|
body.tagHochgeladen,
|
||||||
body.linkField,
|
body.linkField,
|
||||||
body.tagManuell ?? '',
|
body.tagManuell ?? '',
|
||||||
|
body.importWartezeitMinuten ?? '10',
|
||||||
|
body.notizMarker ?? 'Agrarmonitor',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,14 +28,21 @@ export class ApiKeyGuard implements CanActivate {
|
|||||||
if (apiKey) source = 'apiKey query param';
|
if (apiKey) source = 'apiKey query param';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to Authorization: Bearer (used by SSE clients that can't set X-API-Key)
|
// Fallback to Authorization: Bearer (used by SSE clients that can't set
|
||||||
|
// X-API-Key). Nur akzeptieren, wenn das Token wie ein API-Key aussieht
|
||||||
|
// (Präfix "pm_"). Ein (abgelaufenes) JWT als Bearer-Token wird hier ignoriert,
|
||||||
|
// statt es fälschlich als API-Key zu prüfen – das vermeidet die irreführende
|
||||||
|
// "Invalid API Key"-Warnung beim normalen JWT-Ablauf.
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
const auth: string | undefined = request.headers['authorization'];
|
const auth: string | undefined = request.headers['authorization'];
|
||||||
if (auth?.startsWith('Bearer ')) {
|
if (auth?.startsWith('Bearer ')) {
|
||||||
apiKey = auth.slice(7);
|
const token = auth.slice(7);
|
||||||
|
if (token.startsWith('pm_')) {
|
||||||
|
apiKey = token;
|
||||||
source = 'Authorization: Bearer';
|
source = 'Authorization: Bearer';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` +
|
`[${method} ${url}] key source: ${apiKey ? source : 'NONE'} | ` +
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
CorrespondentEmailMapping,
|
CorrespondentEmailMapping,
|
||||||
UserSettings,
|
UserSettings,
|
||||||
LabelPrintJob,
|
LabelPrintJob,
|
||||||
|
WebhookQueueItem,
|
||||||
} from './entities';
|
} from './entities';
|
||||||
|
|
||||||
// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker,
|
// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker,
|
||||||
@@ -57,6 +58,7 @@ export const entities = [
|
|||||||
CorrespondentEmailMapping,
|
CorrespondentEmailMapping,
|
||||||
UserSettings,
|
UserSettings,
|
||||||
LabelPrintJob,
|
LabelPrintJob,
|
||||||
|
WebhookQueueItem,
|
||||||
];
|
];
|
||||||
|
|
||||||
const isProduction = process.env.NODE_ENV === 'production';
|
const isProduction = process.env.NODE_ENV === 'production';
|
||||||
|
|||||||
@@ -21,3 +21,4 @@ export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity'
|
|||||||
export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity';
|
export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity';
|
||||||
export { UserSettings } from './user-settings.entity';
|
export { UserSettings } from './user-settings.entity';
|
||||||
export { LabelPrintJob } from './label-print-job.entity';
|
export { LabelPrintJob } from './label-print-job.entity';
|
||||||
|
export { WebhookQueueItem } from './webhook-queue-item.entity';
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Entity,
|
||||||
|
PrimaryColumn,
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Index,
|
||||||
|
} from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistente Warteschlange der vom Paperless-Webhook gemeldeten Dokument-IDs.
|
||||||
|
* `documentId` ist Primärschlüssel und erzwingt damit, dass jede ID nur einmal
|
||||||
|
* in der Warteschlange steht (Dedup auf DB-Ebene). Die Tabelle übersteht
|
||||||
|
* Neustarts; ausstehende IDs werden nach dem Boot weiterverarbeitet.
|
||||||
|
*/
|
||||||
|
@Entity('webhook_queue')
|
||||||
|
export class WebhookQueueItem {
|
||||||
|
@PrimaryColumn({ type: 'int' })
|
||||||
|
documentId!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'varchar', length: 100, nullable: true })
|
||||||
|
action!: string | null;
|
||||||
|
|
||||||
|
@Index()
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt die persistente Webhook-Warteschlange an (`webhook_queue`).
|
||||||
|
* `documentId` ist Primärschlüssel → jede Dokument-ID kommt nur einmal vor.
|
||||||
|
*/
|
||||||
|
export class CreateWebhookQueue1782700000000 implements MigrationInterface {
|
||||||
|
name = 'CreateWebhookQueue1782700000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS \`webhook_queue\` (
|
||||||
|
\`documentId\` int NOT NULL,
|
||||||
|
\`action\` varchar(100) NULL,
|
||||||
|
\`createdAt\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (\`documentId\`),
|
||||||
|
INDEX \`IDX_webhook_queue_createdAt\` (\`createdAt\`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE \`webhook_queue\``);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,14 @@ import { Attachment } from '../database/entities/attachment.entity';
|
|||||||
import { Content } from '../database/entities/content.entity';
|
import { Content } from '../database/entities/content.entity';
|
||||||
import { isERechnung } from './zugferd.util';
|
import { isERechnung } from './zugferd.util';
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[/\\]/g, '-')
|
||||||
|
.replace(/\.\./g, '.')
|
||||||
|
.replace(/\x00/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailDownloadService {
|
export class EmailDownloadService {
|
||||||
private readonly logger = new Logger(EmailDownloadService.name);
|
private readonly logger = new Logger(EmailDownloadService.name);
|
||||||
@@ -291,7 +299,7 @@ export class EmailDownloadService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachment = new Attachment();
|
const attachment = new Attachment();
|
||||||
attachment.FileName = filename.slice(0, 255);
|
attachment.FileName = sanitizeFilename(filename).slice(0, 255);
|
||||||
attachment.ContentType = contentType.slice(0, 100);
|
attachment.ContentType = contentType.slice(0, 100);
|
||||||
attachment.IsEmbedded = isEmbedded;
|
attachment.IsEmbedded = isEmbedded;
|
||||||
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
|
attachment.ContentId = att.cid ? att.cid.slice(0, 255) : null;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Content } from '../database/entities/content.entity';
|
|||||||
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
||||||
import { Task } from '../database/entities/task.entity';
|
import { Task } from '../database/entities/task.entity';
|
||||||
import { PaperlessService } from '../paperless/paperless.service';
|
import { PaperlessService } from '../paperless/paperless.service';
|
||||||
|
import { deriveTaskMetadata } from '../paperless/task-metadata.util';
|
||||||
import * as QRCode from 'qrcode';
|
import * as QRCode from 'qrcode';
|
||||||
import { EmailPageCacheService } from './email-page-cache.service';
|
import { EmailPageCacheService } from './email-page-cache.service';
|
||||||
import { ImapFolderService } from './imap-folder.service';
|
import { ImapFolderService } from './imap-folder.service';
|
||||||
@@ -19,6 +20,14 @@ import * as os from 'os';
|
|||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/[/\\]/g, '-')
|
||||||
|
.replace(/\.\./g, '.')
|
||||||
|
.replace(/\x00/g, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class EmailImportService {
|
export class EmailImportService {
|
||||||
private readonly logger = new Logger(EmailImportService.name);
|
private readonly logger = new Logger(EmailImportService.name);
|
||||||
@@ -491,7 +500,7 @@ export class EmailImportService {
|
|||||||
if (!content) continue;
|
if (!content) continue;
|
||||||
|
|
||||||
const originalPdfBytes = content.Content1;
|
const originalPdfBytes = content.Content1;
|
||||||
const baseFilename = attachmentEntity.FileName.replace(/\.pdf$/i, '');
|
const baseFilename = sanitizeFilename(attachmentEntity.FileName.replace(/\.pdf$/i, ''));
|
||||||
const paperlessIds: any = {};
|
const paperlessIds: any = {};
|
||||||
|
|
||||||
const uploadPromises = [];
|
const uploadPromises = [];
|
||||||
@@ -557,13 +566,29 @@ export class EmailImportService {
|
|||||||
|
|
||||||
// Upload all generated PDFs
|
// Upload all generated PDFs
|
||||||
for (const uploadItem of uploadPromises) {
|
for (const uploadItem of uploadPromises) {
|
||||||
|
// Einmal definieren, zweimal verwenden: Upload-Metadaten und Task
|
||||||
|
// tragen garantiert dieselben Werte
|
||||||
|
const taskFields = {
|
||||||
|
InterneBelegnummer: att.belegnummer || '',
|
||||||
|
Eingangsdatum: att.barcode?.datum
|
||||||
|
? new Date(att.barcode.datum)
|
||||||
|
: createdDate,
|
||||||
|
Belegdatum: createdDate,
|
||||||
|
DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage
|
||||||
|
BetriebID: null, // Owner-Entfernung erledigt der Task-Processor
|
||||||
|
};
|
||||||
|
// Metadaten direkt beim Upload mitgeben; der Task-Processor patcht
|
||||||
|
// später idempotent nach (Sicherheitsnetz)
|
||||||
|
const derived = deriveTaskMetadata(taskFields);
|
||||||
const options: any = {
|
const options: any = {
|
||||||
filename: uploadItem.filename,
|
filename: uploadItem.filename,
|
||||||
title: att.belegnummer
|
title: att.belegnummer
|
||||||
? `Beleg ${att.belegnummer}`
|
? `Beleg ${att.belegnummer}`
|
||||||
: uploadItem.filename,
|
: uploadItem.filename,
|
||||||
created: createdDate,
|
created: createdDate,
|
||||||
owner: null,
|
documentType: derived.documentType,
|
||||||
|
archiveSerialNumber: derived.archiveSerialNumber,
|
||||||
|
customFields: derived.customFields,
|
||||||
};
|
};
|
||||||
if (att.paperlessCorrespondentId)
|
if (att.paperlessCorrespondentId)
|
||||||
options.correspondent = att.paperlessCorrespondentId;
|
options.correspondent = att.paperlessCorrespondentId;
|
||||||
@@ -577,15 +602,9 @@ export class EmailImportService {
|
|||||||
// Create background task for enrichment (same logic as Inbox)
|
// Create background task for enrichment (same logic as Inbox)
|
||||||
const backgroundTask = this.taskRepo.create({
|
const backgroundTask = this.taskRepo.create({
|
||||||
TaskId: paperlessTaskId,
|
TaskId: paperlessTaskId,
|
||||||
InterneBelegnummer: att.belegnummer || '',
|
...taskFields,
|
||||||
Eingangsdatum: att.barcode?.datum
|
|
||||||
? new Date(att.barcode.datum)
|
|
||||||
: createdDate,
|
|
||||||
Belegdatum: createdDate,
|
|
||||||
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
|
BarcodeJson: att.barcode ? JSON.stringify(att.barcode) : null,
|
||||||
BetriebID: null, // Owner
|
|
||||||
Fertig: 0,
|
Fertig: 0,
|
||||||
DocumentType: att.type === 'MAIN' ? null : 5, // 5 = Anlage
|
|
||||||
SourceAttachmentID: att.attachmentId,
|
SourceAttachmentID: att.attachmentId,
|
||||||
SourceAttachmentRange: uploadItem.rangeKey,
|
SourceAttachmentRange: uploadItem.rangeKey,
|
||||||
});
|
});
|
||||||
@@ -648,10 +667,16 @@ export class EmailImportService {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`,
|
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`,
|
||||||
);
|
);
|
||||||
const emailEntity = await this.emailRepo.findOne({ where: { Id: firstAtt.EmailMessageId } });
|
const emailEntity = await this.emailRepo.findOne({
|
||||||
|
where: { Id: firstAtt.EmailMessageId },
|
||||||
|
});
|
||||||
if (emailEntity) {
|
if (emailEntity) {
|
||||||
this.imapFolderService.moveToImportiert(emailEntity.MessageId).catch(err =>
|
this.imapFolderService
|
||||||
this.logger.error('IMAP-Verschieben fehlgeschlagen: ' + err.message),
|
.moveToImportiert(emailEntity.MessageId)
|
||||||
|
.catch((err) =>
|
||||||
|
this.logger.error(
|
||||||
|
'IMAP-Verschieben fehlgeschlagen: ' + err.message,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,9 @@ export class EmailController {
|
|||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`,
|
`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`,
|
||||||
);
|
);
|
||||||
this.imapFolderService.cleanupImportedEmails().catch(err =>
|
this.imapFolderService
|
||||||
|
.cleanupImportedEmails()
|
||||||
|
.catch((err) =>
|
||||||
this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message),
|
this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -214,8 +216,13 @@ export class EmailController {
|
|||||||
where: [{ Status: 1 }, { Status: 3 }],
|
where: [{ Status: 1 }, { Status: 3 }],
|
||||||
select: ['MessageId'],
|
select: ['MessageId'],
|
||||||
});
|
});
|
||||||
const messageIds = processedEmails.map((e) => e.MessageId).filter(Boolean);
|
const messageIds = processedEmails
|
||||||
movedToImportiert = await this.imapFolderService.moveProcessedInboxToImportiert(messageIds);
|
.map((e) => e.MessageId)
|
||||||
|
.filter(Boolean);
|
||||||
|
movedToImportiert =
|
||||||
|
await this.imapFolderService.moveProcessedInboxToImportiert(
|
||||||
|
messageIds,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { updatedCount, idsUpdated, movedToImportiert };
|
return { updatedCount, idsUpdated, movedToImportiert };
|
||||||
|
|||||||
@@ -25,8 +25,14 @@ export class ImapFolderService {
|
|||||||
@Cron('0 3 * * *', { timeZone: 'Europe/Berlin' })
|
@Cron('0 3 * * *', { timeZone: 'Europe/Berlin' })
|
||||||
async cleanupImportedEmails(): Promise<void> {
|
async cleanupImportedEmails(): Promise<void> {
|
||||||
if (!this.configService.get<string>('IMAP_HOST')) return;
|
if (!this.configService.get<string>('IMAP_HOST')) return;
|
||||||
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
|
const importedFolder = this.configService.get<string>(
|
||||||
const trashFolder = this.configService.get<string>('IMAP_TRASH_FOLDER', 'Trash');
|
'IMAP_IMPORTED_FOLDER',
|
||||||
|
'importiert',
|
||||||
|
);
|
||||||
|
const trashFolder = this.configService.get<string>(
|
||||||
|
'IMAP_TRASH_FOLDER',
|
||||||
|
'Trash',
|
||||||
|
);
|
||||||
const client = this.createClient();
|
const client = this.createClient();
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
@@ -39,10 +45,14 @@ export class ImapFolderService {
|
|||||||
const oldUids = await client.search({ before: cutoff }, { uid: true });
|
const oldUids = await client.search({ before: cutoff }, { uid: true });
|
||||||
if (Array.isArray(oldUids) && oldUids.length > 0) {
|
if (Array.isArray(oldUids) && oldUids.length > 0) {
|
||||||
await client.messageMove(oldUids, trashFolder, { uid: true });
|
await client.messageMove(oldUids, trashFolder, { uid: true });
|
||||||
this.logger.log(`${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`);
|
this.logger.log(
|
||||||
|
`${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Papierkorb leeren
|
// Papierkorb leeren
|
||||||
@@ -51,10 +61,14 @@ export class ImapFolderService {
|
|||||||
const trashUids = await client.search({ all: true }, { uid: true });
|
const trashUids = await client.search({ all: true }, { uid: true });
|
||||||
if (Array.isArray(trashUids) && trashUids.length > 0) {
|
if (Array.isArray(trashUids) && trashUids.length > 0) {
|
||||||
await client.messageDelete(trashUids, { uid: true });
|
await client.messageDelete(trashUids, { uid: true });
|
||||||
this.logger.log(`${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`);
|
this.logger.log(
|
||||||
|
`${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`);
|
this.logger.warn(
|
||||||
|
`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`);
|
this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`);
|
||||||
@@ -64,9 +78,13 @@ export class ImapFolderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async moveProcessedInboxToImportiert(messageIds: string[]): Promise<number> {
|
async moveProcessedInboxToImportiert(messageIds: string[]): Promise<number> {
|
||||||
if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0) return 0;
|
if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
|
const importedFolder = this.configService.get<string>(
|
||||||
|
'IMAP_IMPORTED_FOLDER',
|
||||||
|
'importiert',
|
||||||
|
);
|
||||||
const client = this.createClient();
|
const client = this.createClient();
|
||||||
let movedCount = 0;
|
let movedCount = 0;
|
||||||
|
|
||||||
@@ -74,7 +92,7 @@ export class ImapFolderService {
|
|||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const mailboxes = await client.list();
|
const mailboxes = await client.list();
|
||||||
if (!mailboxes.some(m => m.path === importedFolder)) {
|
if (!mailboxes.some((m) => m.path === importedFolder)) {
|
||||||
await client.mailboxCreate(importedFolder);
|
await client.mailboxCreate(importedFolder);
|
||||||
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
|
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
|
||||||
}
|
}
|
||||||
@@ -86,7 +104,10 @@ export class ImapFolderService {
|
|||||||
const idSet = new Set(messageIds.map(normalize));
|
const idSet = new Set(messageIds.map(normalize));
|
||||||
const uidsToMove: number[] = [];
|
const uidsToMove: number[] = [];
|
||||||
|
|
||||||
for await (const msg of client.fetch('1:*', { uid: true, envelope: true })) {
|
for await (const msg of client.fetch('1:*', {
|
||||||
|
uid: true,
|
||||||
|
envelope: true,
|
||||||
|
})) {
|
||||||
const msgId = msg.envelope?.messageId;
|
const msgId = msg.envelope?.messageId;
|
||||||
if (msgId && idSet.has(normalize(msgId))) {
|
if (msgId && idSet.has(normalize(msgId))) {
|
||||||
uidsToMove.push(msg.uid);
|
uidsToMove.push(msg.uid);
|
||||||
@@ -96,10 +117,14 @@ export class ImapFolderService {
|
|||||||
if (uidsToMove.length > 0) {
|
if (uidsToMove.length > 0) {
|
||||||
await client.messageMove(uidsToMove, importedFolder, { uid: true });
|
await client.messageMove(uidsToMove, importedFolder, { uid: true });
|
||||||
movedCount = uidsToMove.length;
|
movedCount = uidsToMove.length;
|
||||||
this.logger.log(`${movedCount} E-Mail(s) aus INBOX → "${importedFolder}" verschoben.`);
|
this.logger.log(
|
||||||
|
`${movedCount} E-Mail(s) aus INBOX → "${importedFolder}" verschoben.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`);
|
this.logger.error(
|
||||||
|
`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
await client.logout().catch(() => {});
|
await client.logout().catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -110,24 +135,34 @@ export class ImapFolderService {
|
|||||||
async moveToImportiert(messageId: string): Promise<void> {
|
async moveToImportiert(messageId: string): Promise<void> {
|
||||||
if (!this.configService.get<string>('IMAP_HOST')) return;
|
if (!this.configService.get<string>('IMAP_HOST')) return;
|
||||||
|
|
||||||
const importedFolder = this.configService.get<string>('IMAP_IMPORTED_FOLDER', 'importiert');
|
const importedFolder = this.configService.get<string>(
|
||||||
|
'IMAP_IMPORTED_FOLDER',
|
||||||
|
'importiert',
|
||||||
|
);
|
||||||
const client = this.createClient();
|
const client = this.createClient();
|
||||||
try {
|
try {
|
||||||
await client.connect();
|
await client.connect();
|
||||||
|
|
||||||
const mailboxes = await client.list();
|
const mailboxes = await client.list();
|
||||||
if (!mailboxes.some(m => m.path === importedFolder)) {
|
if (!mailboxes.some((m) => m.path === importedFolder)) {
|
||||||
await client.mailboxCreate(importedFolder);
|
await client.mailboxCreate(importedFolder);
|
||||||
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
|
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.mailboxOpen('INBOX');
|
await client.mailboxOpen('INBOX');
|
||||||
const uids = await client.search({ header: { 'message-id': messageId } }, { uid: true });
|
const uids = await client.search(
|
||||||
|
{ header: { 'message-id': messageId } },
|
||||||
|
{ uid: true },
|
||||||
|
);
|
||||||
if (Array.isArray(uids) && uids.length > 0) {
|
if (Array.isArray(uids) && uids.length > 0) {
|
||||||
await client.messageMove(uids, importedFolder, { uid: true });
|
await client.messageMove(uids, importedFolder, { uid: true });
|
||||||
this.logger.log(`E-Mail ${messageId} → "${importedFolder}" verschoben.`);
|
this.logger.log(
|
||||||
|
`E-Mail ${messageId} → "${importedFolder}" verschoben.`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`);
|
this.logger.warn(
|
||||||
|
`E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`);
|
this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`);
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export class PaperlessProcessorService {
|
|||||||
doc.correspondent !== null && doc.correspondent !== undefined;
|
doc.correspondent !== null && doc.correspondent !== undefined;
|
||||||
break;
|
break;
|
||||||
case 2:
|
case 2:
|
||||||
isFilled = !!doc.created || !!doc.created_date;
|
isFilled = !!doc.created;
|
||||||
break;
|
break;
|
||||||
case 3:
|
case 3:
|
||||||
isFilled =
|
isFilled =
|
||||||
|
|||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { PaperlessTaskProcessorService } from './paperless-task-processor.service';
|
||||||
|
import { Task } from '../database/entities/task.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Charakterisierungstest für processSuccessfulTask: pinnt den PATCH-Payload
|
||||||
|
* fest, damit der Refactor auf deriveTaskMetadata() verhaltensidentisch bleibt.
|
||||||
|
* custom_fields wird ordnungsunabhängig verglichen (Paperless behandelt das
|
||||||
|
* Array als Menge).
|
||||||
|
*/
|
||||||
|
|
||||||
|
function createTask(overrides: Partial<Task> = {}): Task {
|
||||||
|
return {
|
||||||
|
TaskId: 'task-uuid-1',
|
||||||
|
InterneBelegnummer: '',
|
||||||
|
DocumentType: null,
|
||||||
|
Eingangsdatum: null,
|
||||||
|
Fertig: 0,
|
||||||
|
Tags: null,
|
||||||
|
BetriebID: null,
|
||||||
|
Lieferant: null,
|
||||||
|
externeBelegnummer: null,
|
||||||
|
EinkaufID: null,
|
||||||
|
Belegdatum: null,
|
||||||
|
PaperlessDocumentID: null,
|
||||||
|
TaskReferenceID: null,
|
||||||
|
BarcodeJson: null,
|
||||||
|
DuplikatZU: null,
|
||||||
|
CustomFieldsJson: null,
|
||||||
|
Asn: null,
|
||||||
|
SourceAttachmentID: null,
|
||||||
|
SourceAttachmentRange: null,
|
||||||
|
...overrides,
|
||||||
|
} as Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortByField(fields: Array<{ field: number }>) {
|
||||||
|
return [...fields].sort((a, b) => a.field - b.field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Erwartete Form des PATCH-Payloads an updateDocument. */
|
||||||
|
interface PatchPayload {
|
||||||
|
custom_fields: Array<{ field: number; value: unknown }>;
|
||||||
|
archive_serial_number?: number;
|
||||||
|
document_type?: number;
|
||||||
|
created?: string;
|
||||||
|
owner?: number | null;
|
||||||
|
tags?: number[];
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PaperlessTaskProcessorService.processSuccessfulTask', () => {
|
||||||
|
let paperlessService: {
|
||||||
|
getDocument: jest.Mock;
|
||||||
|
updateDocument: jest.Mock;
|
||||||
|
addNote: jest.Mock;
|
||||||
|
getDocumentMetadata: jest.Mock;
|
||||||
|
getTask: jest.Mock;
|
||||||
|
};
|
||||||
|
let taskRepo: { save: jest.Mock };
|
||||||
|
let documentRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock };
|
||||||
|
let attachmentRepo: { findOne: jest.Mock; save: jest.Mock };
|
||||||
|
let service: PaperlessTaskProcessorService;
|
||||||
|
|
||||||
|
/** Zugriff auf die private Methode, typsicher für den Test gekapselt. */
|
||||||
|
function runProcessSuccessfulTask(
|
||||||
|
task: Task,
|
||||||
|
apiTask: { related_document: number },
|
||||||
|
parentTask: Task | null,
|
||||||
|
): Promise<void> {
|
||||||
|
return (
|
||||||
|
service as unknown as {
|
||||||
|
processSuccessfulTask: (
|
||||||
|
t: Task,
|
||||||
|
a: { related_document: number },
|
||||||
|
p: Task | null,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
).processSuccessfulTask(task, apiTask, parentTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastPatchPayload(): PatchPayload {
|
||||||
|
const calls = paperlessService.updateDocument.mock.calls as Array<
|
||||||
|
[number, PatchPayload]
|
||||||
|
>;
|
||||||
|
return calls[calls.length - 1][1];
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
paperlessService = {
|
||||||
|
getDocument: jest.fn(),
|
||||||
|
updateDocument: jest.fn().mockResolvedValue(undefined),
|
||||||
|
addNote: jest.fn().mockResolvedValue(undefined),
|
||||||
|
getDocumentMetadata: jest.fn().mockResolvedValue({
|
||||||
|
original_checksum: 'abc',
|
||||||
|
original_filename: 'test.pdf',
|
||||||
|
}),
|
||||||
|
getTask: jest.fn(),
|
||||||
|
};
|
||||||
|
taskRepo = { save: jest.fn().mockResolvedValue(undefined) };
|
||||||
|
documentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn((v: object) => v),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
attachmentRepo = {
|
||||||
|
findOne: jest.fn().mockResolvedValue(null),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
service = new PaperlessTaskProcessorService(
|
||||||
|
taskRepo as any,
|
||||||
|
documentRepo as any,
|
||||||
|
attachmentRepo as any,
|
||||||
|
paperlessService as any,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('baut den PATCH-Payload mit ASN, CF3/CF7/CF9, Typ, created, owner und Tag-Merge', async () => {
|
||||||
|
paperlessService.getDocument.mockResolvedValue({
|
||||||
|
id: 101,
|
||||||
|
title: 'Testdokument',
|
||||||
|
custom_fields: [{ field: 7, value: 'alt' }],
|
||||||
|
tags: [99],
|
||||||
|
});
|
||||||
|
const task = createTask({
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
externeBelegnummer: 'RE-9',
|
||||||
|
Eingangsdatum: new Date('2026-07-14T00:00:00Z'),
|
||||||
|
Belegdatum: new Date('2026-07-01T00:00:00Z'),
|
||||||
|
DocumentType: 2,
|
||||||
|
BetriebID: 3,
|
||||||
|
Tags: '1,2',
|
||||||
|
CustomFieldsJson: JSON.stringify({ '4': 'x' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await runProcessSuccessfulTask(task, { related_document: 101 }, null);
|
||||||
|
|
||||||
|
expect(paperlessService.updateDocument).toHaveBeenCalledTimes(1);
|
||||||
|
expect(paperlessService.updateDocument).toHaveBeenCalledWith(
|
||||||
|
101,
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
const payload = lastPatchPayload();
|
||||||
|
expect(payload.archive_serial_number).toBe(2026000123);
|
||||||
|
expect(payload.document_type).toBe(2);
|
||||||
|
expect(payload.created).toBe('2026-07-01T00:00:00.000Z');
|
||||||
|
expect(payload.owner).toBe(3);
|
||||||
|
expect(payload.tags).toEqual([99, 1, 2]);
|
||||||
|
expect(sortByField(payload.custom_fields)).toEqual([
|
||||||
|
{ field: 3, value: 'RE-9' },
|
||||||
|
{ field: 4, value: 'x' },
|
||||||
|
{ field: 7, value: '2026-000123' },
|
||||||
|
{ field: 9, value: '2026-07-14' },
|
||||||
|
]);
|
||||||
|
expect(task.Fertig).toBe(1);
|
||||||
|
expect(task.PaperlessDocumentID).toBe(101);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt den Owner (null) bei leerer BetriebID', async () => {
|
||||||
|
paperlessService.getDocument.mockResolvedValue({
|
||||||
|
id: 102,
|
||||||
|
title: 'Ohne Betrieb',
|
||||||
|
custom_fields: [],
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
const task = createTask({ InterneBelegnummer: '2026-000124' });
|
||||||
|
|
||||||
|
await runProcessSuccessfulTask(task, { related_document: 102 }, null);
|
||||||
|
|
||||||
|
expect(lastPatchPayload().owner).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('überschreibt bei Anlagen document_type/title und verknüpft CF8 zum Elterndokument', async () => {
|
||||||
|
paperlessService.getDocument
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
id: 103,
|
||||||
|
title: 'Anlage-Roh',
|
||||||
|
custom_fields: [],
|
||||||
|
tags: [],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({ id: 55, title: 'Eltern' });
|
||||||
|
paperlessService.getTask.mockResolvedValue([{ related_document: 55 }]);
|
||||||
|
const task = createTask({
|
||||||
|
DocumentType: 2,
|
||||||
|
TaskReferenceID: 'parent-uuid',
|
||||||
|
});
|
||||||
|
const parentTask = createTask({
|
||||||
|
TaskId: 'parent-uuid',
|
||||||
|
InterneBelegnummer: '2026-000100',
|
||||||
|
Fertig: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
await runProcessSuccessfulTask(task, { related_document: 103 }, parentTask);
|
||||||
|
|
||||||
|
const payload = lastPatchPayload();
|
||||||
|
expect(payload.document_type).toBe(5);
|
||||||
|
expect(payload.title).toBe('Anlage zu 2026-000100');
|
||||||
|
expect(sortByField(payload.custom_fields)).toEqual([
|
||||||
|
{ field: 8, value: [55] },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import { Task } from '../database/entities/task.entity';
|
|||||||
import { Document } from '../database/entities/document.entity';
|
import { Document } from '../database/entities/document.entity';
|
||||||
import { Attachment } from '../database/entities/attachment.entity';
|
import { Attachment } from '../database/entities/attachment.entity';
|
||||||
import { PaperlessService } from './paperless.service';
|
import { PaperlessService } from './paperless.service';
|
||||||
|
import { deriveTaskMetadata } from './task-metadata.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PaperlessTaskProcessorService {
|
export class PaperlessTaskProcessorService {
|
||||||
@@ -74,7 +75,12 @@ export class PaperlessTaskProcessorService {
|
|||||||
await this.processSuccessfulTask(t, apiResponseTask, parentTask);
|
await this.processSuccessfulTask(t, apiResponseTask, parentTask);
|
||||||
}
|
}
|
||||||
} else if (apiResponseTask.status === 'FAILURE') {
|
} else if (apiResponseTask.status === 'FAILURE') {
|
||||||
this.logger.warn(`Task ${t.TaskId} failed in Paperless`);
|
// Seit Metadaten (inkl. ASN) direkt beim Upload gesetzt werden, kann
|
||||||
|
// z.B. eine ASN-Kollision bereits den Consume scheitern lassen –
|
||||||
|
// vor dem Löschen die Diagnose-Infos festhalten
|
||||||
|
this.logger.error(
|
||||||
|
`Task ${t.TaskId} in Paperless fehlgeschlagen (Beleg: ${t.InterneBelegnummer || '-'}, Attachment: ${t.SourceAttachmentID ?? '-'}). Paperless-Meldung: ${apiResponseTask.result ?? 'keine'}`,
|
||||||
|
);
|
||||||
toDelete.push(t);
|
toDelete.push(t);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -200,104 +206,34 @@ export class PaperlessTaskProcessorService {
|
|||||||
: [],
|
: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// CustomFieldsJson als Basis zuerst anwenden – dedizierte Felder weiter unten überschreiben diese
|
// Gemeinsame Ableitung – identische Regeln wie beim direkten Upload (task-metadata.util)
|
||||||
if (t.CustomFieldsJson) {
|
const derived = deriveTaskMetadata(t);
|
||||||
try {
|
|
||||||
const extra = JSON.parse(t.CustomFieldsJson) as Record<
|
for (const [k, v] of Object.entries(derived.customFields)) {
|
||||||
string,
|
|
||||||
string
|
|
||||||
>;
|
|
||||||
for (const [k, v] of Object.entries(extra)) {
|
|
||||||
const fieldId = parseInt(k, 10);
|
const fieldId = parseInt(k, 10);
|
||||||
if (!Number.isFinite(fieldId)) continue;
|
|
||||||
const idx = updateData.custom_fields.findIndex(
|
const idx = updateData.custom_fields.findIndex(
|
||||||
(f: any) => f.field === fieldId,
|
(f: any) => f.field === fieldId,
|
||||||
);
|
);
|
||||||
if (idx !== -1) updateData.custom_fields[idx].value = v;
|
if (idx !== -1) updateData.custom_fields[idx].value = v;
|
||||||
else updateData.custom_fields.push({ field: fieldId, value: v });
|
else updateData.custom_fields.push({ field: fieldId, value: v });
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
/* JSON-Parse-Fehler ignorieren */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.Asn) {
|
if (derived.archiveSerialNumber !== undefined) {
|
||||||
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
|
|
||||||
if (!isNaN(asnNum)) {
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze ASN (explizit): ${asnNum}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze ASN: ${derived.archiveSerialNumber}`,
|
||||||
);
|
);
|
||||||
updateData.archive_serial_number = asnNum;
|
updateData.archive_serial_number = derived.archiveSerialNumber;
|
||||||
}
|
} else if (t.Asn || t.InterneBelegnummer) {
|
||||||
}
|
|
||||||
|
|
||||||
if (t.InterneBelegnummer) {
|
|
||||||
this.logger.log(
|
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze InterneBelegnummer: ${t.InterneBelegnummer}`,
|
|
||||||
);
|
|
||||||
if (!t.Asn) {
|
|
||||||
const asnFromBelegnummer = parseInt(
|
|
||||||
t.InterneBelegnummer.replace(/-/g, ''),
|
|
||||||
10,
|
|
||||||
);
|
|
||||||
if (!isNaN(asnFromBelegnummer)) {
|
|
||||||
updateData.archive_serial_number = asnFromBelegnummer;
|
|
||||||
} else {
|
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[Postprocessing] Task ${t.TaskId} - ASN aus InterneBelegnummer konnte nicht geparst werden: ${t.InterneBelegnummer}`,
|
`[Postprocessing] Task ${t.TaskId} - ASN konnte nicht abgeleitet werden (Asn: ${t.Asn ?? '-'}, InterneBelegnummer: ${t.InterneBelegnummer || '-'})`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const existingField7 = updateData.custom_fields.find(
|
|
||||||
(f: any) => f.field === 7,
|
|
||||||
);
|
|
||||||
if (existingField7) {
|
|
||||||
existingField7.value = t.InterneBelegnummer;
|
|
||||||
} else {
|
|
||||||
updateData.custom_fields.push({
|
|
||||||
field: 7,
|
|
||||||
value: t.InterneBelegnummer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.externeBelegnummer) {
|
if (derived.documentType !== undefined) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze externeBelegnummer: ${t.externeBelegnummer}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${derived.documentType}`,
|
||||||
);
|
);
|
||||||
const existingField3 = updateData.custom_fields.find(
|
updateData.document_type = derived.documentType;
|
||||||
(f: any) => f.field === 3,
|
|
||||||
);
|
|
||||||
if (existingField3) {
|
|
||||||
existingField3.value = t.externeBelegnummer;
|
|
||||||
} else {
|
|
||||||
updateData.custom_fields.push({
|
|
||||||
field: 3,
|
|
||||||
value: t.externeBelegnummer,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.Eingangsdatum) {
|
|
||||||
const dateValue = new Date(t.Eingangsdatum).toISOString().split('T')[0];
|
|
||||||
this.logger.log(
|
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze Eingangsdatum: ${dateValue}`,
|
|
||||||
);
|
|
||||||
const existingField9 = updateData.custom_fields.find(
|
|
||||||
(f: any) => f.field === 9,
|
|
||||||
);
|
|
||||||
if (existingField9) {
|
|
||||||
existingField9.value = dateValue;
|
|
||||||
} else {
|
|
||||||
updateData.custom_fields.push({ field: 9, value: dateValue });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (t.DocumentType) {
|
|
||||||
this.logger.log(
|
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze DocumentType: ${t.DocumentType}`,
|
|
||||||
);
|
|
||||||
updateData.document_type = t.DocumentType;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent Task / Attachment logic
|
// Parent Task / Attachment logic
|
||||||
@@ -350,36 +286,33 @@ export class PaperlessTaskProcessorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.Belegdatum) {
|
if (derived.created) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${t.Belegdatum.toISOString()}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze Belegdatum: ${derived.created}`,
|
||||||
);
|
);
|
||||||
updateData.created = t.Belegdatum.toISOString();
|
updateData.created = derived.created;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (t.BetriebID) {
|
if (derived.owner !== undefined) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${t.BetriebID}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze Owner: ${derived.owner}`,
|
||||||
);
|
);
|
||||||
updateData.owner = t.BetriebID;
|
|
||||||
} else {
|
} else {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`,
|
`[Postprocessing] Task ${t.TaskId} - Entferne Owner (setze null)`,
|
||||||
);
|
);
|
||||||
updateData.owner = null;
|
|
||||||
}
|
}
|
||||||
|
updateData.owner = derived.owner ?? null;
|
||||||
|
|
||||||
// Tags
|
// Tags: Upload-/Consume-Tags des Dokuments bleiben erhalten (Merge statt Ersetzen)
|
||||||
if (t.Tags) {
|
if (derived.tags) {
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`,
|
`[Postprocessing] Task ${t.TaskId} - Setze Tags: ${t.Tags}`,
|
||||||
);
|
);
|
||||||
const tagIds = t.Tags.split(',')
|
|
||||||
.map((id) => parseInt(id.trim(), 10))
|
|
||||||
.filter((id) => !isNaN(id));
|
|
||||||
const currentTags = document.tags || [];
|
const currentTags = document.tags || [];
|
||||||
const newTags = Array.from(new Set([...currentTags, ...tagIds]));
|
updateData.tags = Array.from(
|
||||||
updateData.tags = newTags;
|
new Set([...currentTags, ...derived.tags]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Agrarmonitor Link (Skip API call for now, but save the link if needed)
|
// Agrarmonitor Link (Skip API call for now, but save the link if needed)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { PaperlessService } from './paperless.service';
|
import { PaperlessService } from './paperless.service';
|
||||||
|
import { deriveTaskMetadata } from './task-metadata.util';
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||||
import { UploadExternalDto } from './dto/upload-external.dto';
|
import { UploadExternalDto } from './dto/upload-external.dto';
|
||||||
import { Task } from '../database/entities/task.entity';
|
import { Task } from '../database/entities/task.entity';
|
||||||
@@ -155,7 +156,7 @@ export class PaperlessController {
|
|||||||
asn: doc.archive_serial_number,
|
asn: doc.archive_serial_number,
|
||||||
documentType: doc.document_type,
|
documentType: doc.document_type,
|
||||||
correspondent: doc.correspondent,
|
correspondent: doc.correspondent,
|
||||||
created: doc.created_date,
|
created: doc.created,
|
||||||
added: doc.added,
|
added: doc.added,
|
||||||
tags: doc.tags,
|
tags: doc.tags,
|
||||||
customFields: doc.custom_fields,
|
customFields: doc.custom_fields,
|
||||||
@@ -179,7 +180,7 @@ export class PaperlessController {
|
|||||||
asn: doc.archive_serial_number,
|
asn: doc.archive_serial_number,
|
||||||
documentType: doc.document_type,
|
documentType: doc.document_type,
|
||||||
correspondent: doc.correspondent,
|
correspondent: doc.correspondent,
|
||||||
created: doc.created_date,
|
created: doc.created,
|
||||||
added: doc.added,
|
added: doc.added,
|
||||||
tags: doc.tags,
|
tags: doc.tags,
|
||||||
customFields: doc.custom_fields,
|
customFields: doc.custom_fields,
|
||||||
@@ -338,7 +339,7 @@ export class PaperlessController {
|
|||||||
docDate.getHours() * 60 * 60 * 1000,
|
docDate.getHours() * 60 * 60 * 1000,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
oldDocument.created_date = docDate.toISOString().split('T')[0];
|
oldDocument.created = docDate.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfDefinitions = await this.paperlessService.getCustomFields();
|
const cfDefinitions = await this.paperlessService.getCustomFields();
|
||||||
@@ -390,7 +391,7 @@ export class PaperlessController {
|
|||||||
for (const req of reqs) {
|
for (const req of reqs) {
|
||||||
let isFieldValid = false;
|
let isFieldValid = false;
|
||||||
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
|
if (req.Type === 1) isFieldValid = oldDocument.correspondent !== null;
|
||||||
if (req.Type === 2) isFieldValid = oldDocument.created_date !== null;
|
if (req.Type === 2) isFieldValid = oldDocument.created !== null;
|
||||||
if (req.Type === 3)
|
if (req.Type === 3)
|
||||||
isFieldValid = oldDocument.archive_serial_number !== null;
|
isFieldValid = oldDocument.archive_serial_number !== null;
|
||||||
if (req.Type === 4)
|
if (req.Type === 4)
|
||||||
@@ -446,10 +447,8 @@ export class PaperlessController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
titleTemplate = titleTemplate.replace(
|
const createdDatePart = String(oldDocument.created ?? '').split('T')[0];
|
||||||
'{{DATE}}',
|
titleTemplate = titleTemplate.replace('{{DATE}}', createdDatePart);
|
||||||
oldDocument.created_date,
|
|
||||||
);
|
|
||||||
oldDocument.title = titleTemplate;
|
oldDocument.title = titleTemplate;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -471,6 +470,9 @@ export class PaperlessController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Veraltetes Feld nicht mit-PATCHen (löst Paperless-Deprecation-Warnung aus)
|
||||||
|
delete oldDocument.created_date;
|
||||||
|
|
||||||
await this.paperlessService.updateDocument(documentId, oldDocument);
|
await this.paperlessService.updateDocument(documentId, oldDocument);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -513,11 +515,28 @@ export class PaperlessController {
|
|||||||
// 0. Check if ASN already exists
|
// 0. Check if ASN already exists
|
||||||
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
|
await this.paperlessService.validateAsnNotExists(dto.interneBelegnummer);
|
||||||
|
|
||||||
// 1. Forward to Paperless
|
// 1. Forward to Paperless – Metadaten direkt beim Upload mitgeben;
|
||||||
|
// der Task-Processor patcht später idempotent nach (Sicherheitsnetz)
|
||||||
|
const derived = deriveTaskMetadata({
|
||||||
|
InterneBelegnummer: dto.interneBelegnummer,
|
||||||
|
externeBelegnummer: dto.externeBelegnummer ?? null,
|
||||||
|
Eingangsdatum: dto.Eingangsdatum ? new Date(dto.Eingangsdatum) : null,
|
||||||
|
Belegdatum: dto.belegdatum ? new Date(dto.belegdatum) : null,
|
||||||
|
// Anlagen (parentId gesetzt) bekommen Typ 5 sofort; Titel/CF8 folgen im Processor
|
||||||
|
DocumentType: dto.parentId ? 5 : (dto.dokumentType ?? null),
|
||||||
|
Tags: dto.tag ? String(dto.tag) : null,
|
||||||
|
BetriebID: dto.betriebId ?? null,
|
||||||
|
});
|
||||||
const paperlessTaskId = await this.paperlessService.uploadDocument(
|
const paperlessTaskId = await this.paperlessService.uploadDocument(
|
||||||
file.path,
|
file.path,
|
||||||
{
|
{
|
||||||
title: `Beleg ${dto.interneBelegnummer}`,
|
title: `Beleg ${dto.interneBelegnummer}`,
|
||||||
|
created: derived.created,
|
||||||
|
documentType: derived.documentType,
|
||||||
|
owner: derived.owner,
|
||||||
|
tags: derived.tags,
|
||||||
|
archiveSerialNumber: derived.archiveSerialNumber,
|
||||||
|
customFields: derived.customFields,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,14 @@ export class PaperlessService {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getNotes(
|
||||||
|
id: number,
|
||||||
|
): Promise<Array<{ id: number; note: string; created: string }>> {
|
||||||
|
const response = await this.client.get(`/documents/${id}/notes/`);
|
||||||
|
const data = response.data;
|
||||||
|
return Array.isArray(data) ? data : (data?.results ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
async checksumExists(checksum: string): Promise<boolean> {
|
async checksumExists(checksum: string): Promise<boolean> {
|
||||||
const response = await this.client.get('/documents/', {
|
const response = await this.client.get('/documents/', {
|
||||||
params: { checksum__iexact: checksum },
|
params: { checksum__iexact: checksum },
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { deriveTaskMetadata } from './task-metadata.util';
|
||||||
|
|
||||||
|
describe('deriveTaskMetadata', () => {
|
||||||
|
describe('archiveSerialNumber', () => {
|
||||||
|
it('parst ASN aus Asn und entfernt alle Nicht-Ziffern', () => {
|
||||||
|
const result = deriveTaskMetadata({ Asn: 'ASN-123' });
|
||||||
|
expect(result.archiveSerialNumber).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leitet ASN aus InterneBelegnummer ab (nur Bindestriche entfernen)', () => {
|
||||||
|
const result = deriveTaskMetadata({ InterneBelegnummer: '2026-000123' });
|
||||||
|
expect(result.archiveSerialNumber).toBe(2026000123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bevorzugt Asn vor InterneBelegnummer', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Asn: '42',
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
});
|
||||||
|
expect(result.archiveSerialNumber).toBe(42);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nutzt keinen Belegnummer-Fallback, wenn Asn gesetzt aber unparsebar ist', () => {
|
||||||
|
// Entspricht exakt der Processor-Logik: Fallback nur bei leerem Asn
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Asn: 'abc',
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
});
|
||||||
|
expect(result.archiveSerialNumber).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt ASN weg, wenn InterneBelegnummer nicht parsebar ist', () => {
|
||||||
|
const result = deriveTaskMetadata({ InterneBelegnummer: 'Beleg-X' });
|
||||||
|
expect(result.archiveSerialNumber).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('customFields (CF7/CF3/CF9)', () => {
|
||||||
|
it('setzt CF7 aus InterneBelegnummer', () => {
|
||||||
|
const result = deriveTaskMetadata({ InterneBelegnummer: '2026-000123' });
|
||||||
|
expect(result.customFields['7']).toBe('2026-000123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt CF3 aus externeBelegnummer', () => {
|
||||||
|
const result = deriveTaskMetadata({ externeBelegnummer: 'RE-4711' });
|
||||||
|
expect(result.customFields['3']).toBe('RE-4711');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt CF9 aus Eingangsdatum als YYYY-MM-DD', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Eingangsdatum: new Date('2026-07-14T10:30:00Z'),
|
||||||
|
});
|
||||||
|
expect(result.customFields['9']).toBe('2026-07-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('übernimmt CustomFieldsJson als Basis mit normalisierten numerischen Keys', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
CustomFieldsJson: JSON.stringify({ '4': 'wert4', abc: 'ignoriert' }),
|
||||||
|
});
|
||||||
|
expect(result.customFields).toEqual({ '4': 'wert4' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('überschreibt CustomFieldsJson-Basis mit dedizierten Feldern CF7/CF3/CF9', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
externeBelegnummer: 'RE-4711',
|
||||||
|
Eingangsdatum: new Date('2026-07-14T00:00:00Z'),
|
||||||
|
CustomFieldsJson: JSON.stringify({
|
||||||
|
'7': 'alt7',
|
||||||
|
'3': 'alt3',
|
||||||
|
'9': 'alt9',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(result.customFields['7']).toBe('2026-000123');
|
||||||
|
expect(result.customFields['3']).toBe('RE-4711');
|
||||||
|
expect(result.customFields['9']).toBe('2026-07-14');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignoriert ungültiges CustomFieldsJson still', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
CustomFieldsJson: '{kein json',
|
||||||
|
InterneBelegnummer: '2026-000123',
|
||||||
|
});
|
||||||
|
expect(result.customFields).toEqual({ '7': '2026-000123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('liefert leeres customFields-Objekt ohne Eingaben', () => {
|
||||||
|
const result = deriveTaskMetadata({});
|
||||||
|
expect(result.customFields).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documentType / created / owner / tags', () => {
|
||||||
|
it('übernimmt DocumentType nur wenn truthy', () => {
|
||||||
|
expect(deriveTaskMetadata({ DocumentType: 5 }).documentType).toBe(5);
|
||||||
|
expect(
|
||||||
|
deriveTaskMetadata({ DocumentType: 0 }).documentType,
|
||||||
|
).toBeUndefined();
|
||||||
|
expect(
|
||||||
|
deriveTaskMetadata({ DocumentType: null }).documentType,
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leitet created als ISO-String aus Belegdatum ab', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
Belegdatum: new Date('2026-07-01T00:00:00Z'),
|
||||||
|
});
|
||||||
|
expect(result.created).toBe('2026-07-01T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setzt owner nur bei truthy BetriebID', () => {
|
||||||
|
expect(deriveTaskMetadata({ BetriebID: 3 }).owner).toBe(3);
|
||||||
|
expect(deriveTaskMetadata({ BetriebID: null }).owner).toBeUndefined();
|
||||||
|
expect(deriveTaskMetadata({}).owner).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parst Tags-CSV und filtert Ungültiges', () => {
|
||||||
|
const result = deriveTaskMetadata({ Tags: '1, 2, x' });
|
||||||
|
expect(result.tags).toEqual([1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt tags bei leerem Tags-Feld weg', () => {
|
||||||
|
expect(deriveTaskMetadata({ Tags: null }).tags).toBeUndefined();
|
||||||
|
expect(deriveTaskMetadata({}).tags).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('leere Eingaben', () => {
|
||||||
|
it('lässt bei leeren Strings alle Felder weg', () => {
|
||||||
|
const result = deriveTaskMetadata({
|
||||||
|
InterneBelegnummer: '',
|
||||||
|
Asn: '',
|
||||||
|
externeBelegnummer: '',
|
||||||
|
Tags: '',
|
||||||
|
});
|
||||||
|
expect(result).toEqual({ customFields: {} });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/**
|
||||||
|
* Leitet Paperless-Metadaten aus Task-Feldern ab.
|
||||||
|
*
|
||||||
|
* Wird sowohl von den Upload-Pfaden (external-upload, E-Mail-Import) als auch
|
||||||
|
* vom PaperlessTaskProcessorService genutzt, damit beim Upload und beim
|
||||||
|
* nachgelagerten PATCH garantiert dieselben Ableitungsregeln gelten.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Teilmenge der Task-Entity-Felder, aus denen Metadaten abgeleitet werden. */
|
||||||
|
export interface TaskMetadataInput {
|
||||||
|
InterneBelegnummer?: string | null;
|
||||||
|
Asn?: string | null;
|
||||||
|
externeBelegnummer?: string | null;
|
||||||
|
Eingangsdatum?: Date | string | null;
|
||||||
|
Belegdatum?: Date | string | null;
|
||||||
|
DocumentType?: number | null;
|
||||||
|
/** CSV wie Task.Tags, z.B. "1,2,3" */
|
||||||
|
Tags?: string | null;
|
||||||
|
BetriebID?: number | null;
|
||||||
|
CustomFieldsJson?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DerivedTaskMetadata {
|
||||||
|
archiveSerialNumber?: number;
|
||||||
|
/** Basis aus CustomFieldsJson; CF7/CF3/CF9 überschreiben die Basis. */
|
||||||
|
customFields: Record<string, string>;
|
||||||
|
documentType?: number;
|
||||||
|
/** ISO-String aus Belegdatum */
|
||||||
|
created?: string;
|
||||||
|
/** Nur gesetzt, wenn BetriebID truthy — Owner-Entfernung bleibt Sache des Processors. */
|
||||||
|
owner?: number;
|
||||||
|
tags?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTaskMetadata(t: TaskMetadataInput): DerivedTaskMetadata {
|
||||||
|
const result: DerivedTaskMetadata = { customFields: {} };
|
||||||
|
|
||||||
|
// Basis aus CustomFieldsJson – dedizierte Felder unten überschreiben diese
|
||||||
|
if (t.CustomFieldsJson) {
|
||||||
|
try {
|
||||||
|
const extra = JSON.parse(t.CustomFieldsJson) as Record<string, string>;
|
||||||
|
for (const [k, v] of Object.entries(extra)) {
|
||||||
|
const fieldId = parseInt(k, 10);
|
||||||
|
if (!Number.isFinite(fieldId)) continue;
|
||||||
|
result.customFields[String(fieldId)] = v;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* JSON-Parse-Fehler ignorieren */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Asn) {
|
||||||
|
const asnNum = parseInt(t.Asn.replace(/[^0-9]/g, ''), 10);
|
||||||
|
if (!isNaN(asnNum)) {
|
||||||
|
result.archiveSerialNumber = asnNum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.InterneBelegnummer) {
|
||||||
|
// ASN-Fallback nur bei leerem Asn; bewusst nur Bindestriche entfernen
|
||||||
|
if (!t.Asn) {
|
||||||
|
const asnFromBelegnummer = parseInt(
|
||||||
|
t.InterneBelegnummer.replace(/-/g, ''),
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
if (!isNaN(asnFromBelegnummer)) {
|
||||||
|
result.archiveSerialNumber = asnFromBelegnummer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.customFields['7'] = t.InterneBelegnummer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.externeBelegnummer) {
|
||||||
|
result.customFields['3'] = t.externeBelegnummer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Eingangsdatum) {
|
||||||
|
result.customFields['9'] = new Date(t.Eingangsdatum)
|
||||||
|
.toISOString()
|
||||||
|
.split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.DocumentType) {
|
||||||
|
result.documentType = t.DocumentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Belegdatum) {
|
||||||
|
result.created = new Date(t.Belegdatum).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.BetriebID) {
|
||||||
|
result.owner = t.BetriebID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.Tags) {
|
||||||
|
result.tags = t.Tags.split(',')
|
||||||
|
.map((id) => parseInt(id.trim(), 10))
|
||||||
|
.filter((id) => !isNaN(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
// PaperlessProcessorService zieht über die Postprocessing-Kette das ESM-Paket
|
||||||
|
// "webdav" nach, das Jest nicht transformiert. Für diesen Unit-Test ersetzen wir
|
||||||
|
// das Modul durch eine Dummy-Klasse – der Service erhält seine Abhängigkeiten
|
||||||
|
// ohnehin als Mocks injiziert.
|
||||||
|
jest.mock('../paperless/paperless-processor.service', () => ({
|
||||||
|
PaperlessProcessorService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { WebhookQueueService } from './webhook-queue.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baut ein Mock-Repository, das die `webhook_queue`-Tabelle durch ein einfaches
|
||||||
|
* FIFO-Array simuliert: `INSERT IGNORE` (Dedup), FIFO-`find` und `delete`.
|
||||||
|
*/
|
||||||
|
function createQueueRepoMock() {
|
||||||
|
const store: { documentId: number; createdAt: Date }[] = [];
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
insert: () => ({
|
||||||
|
into: () => ({
|
||||||
|
values: (v: { documentId: number }) => ({
|
||||||
|
orIgnore: () => ({
|
||||||
|
execute: () => {
|
||||||
|
const added = !store.some((s) => s.documentId === v.documentId);
|
||||||
|
if (added) {
|
||||||
|
// Standardmäßig "alt genug" (60s), damit die Mindest-Wartezeit
|
||||||
|
// die Verhaltenstests nicht blockiert.
|
||||||
|
store.push({
|
||||||
|
documentId: v.documentId,
|
||||||
|
createdAt: new Date(Date.now() - 60_000),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
raw: { affectedRows: added ? 1 : 0 },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
find: jest.fn(() => {
|
||||||
|
// Simuliert den DB-seitigen Alters-Filter (createdAt <= NOW() - 5000 ms).
|
||||||
|
const eligible = [...store]
|
||||||
|
.filter((s) => Date.now() - s.createdAt.getTime() >= 5000)
|
||||||
|
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
|
||||||
|
return Promise.resolve(
|
||||||
|
eligible.length
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
documentId: eligible[0].documentId,
|
||||||
|
action: null,
|
||||||
|
createdAt: eligible[0].createdAt,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
delete: jest.fn((criteria: { documentId: number }) => {
|
||||||
|
const idx = store.findIndex((s) => s.documentId === criteria.documentId);
|
||||||
|
if (idx >= 0) store.splice(idx, 1);
|
||||||
|
return Promise.resolve({ affected: 1 });
|
||||||
|
}),
|
||||||
|
count: jest.fn(() => Promise.resolve(store.length)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WebhookQueueService', () => {
|
||||||
|
let processor: { processDocumentById: jest.Mock };
|
||||||
|
let settingRepo: { findOneBy: jest.Mock; create: jest.Mock; save: jest.Mock };
|
||||||
|
let queueRepo: ReturnType<typeof createQueueRepoMock>;
|
||||||
|
let service: WebhookQueueService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
processor = {
|
||||||
|
processDocumentById: jest.fn().mockResolvedValue({ processed: true }),
|
||||||
|
};
|
||||||
|
settingRepo = {
|
||||||
|
findOneBy: jest.fn().mockResolvedValue(null),
|
||||||
|
create: jest.fn((x: unknown) => x),
|
||||||
|
save: jest.fn().mockResolvedValue(undefined),
|
||||||
|
};
|
||||||
|
queueRepo = createQueueRepoMock();
|
||||||
|
service = new WebhookQueueService(
|
||||||
|
processor as never,
|
||||||
|
settingRepo as never,
|
||||||
|
queueRepo as never,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reiht jede ID nur einmal ein (Dedup)', async () => {
|
||||||
|
await service.enqueue(5);
|
||||||
|
await service.enqueue(5);
|
||||||
|
expect(queueRepo.store.map((s) => s.documentId)).toEqual([5]);
|
||||||
|
expect(await service.count()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verarbeitet alle eingereihten IDs und leert die Warteschlange', async () => {
|
||||||
|
await service.enqueue(1);
|
||||||
|
await service.enqueue(2);
|
||||||
|
|
||||||
|
await service.processQueue();
|
||||||
|
|
||||||
|
expect(processor.processDocumentById).toHaveBeenCalledWith(1);
|
||||||
|
expect(processor.processDocumentById).toHaveBeenCalledWith(2);
|
||||||
|
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
|
||||||
|
expect(queueRepo.store).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('entfernt die ID vor Verarbeitungsbeginn aus der Tabelle', async () => {
|
||||||
|
let containedWhileProcessing = true;
|
||||||
|
processor.processDocumentById.mockImplementation((id: number) => {
|
||||||
|
containedWhileProcessing = queueRepo.store.some(
|
||||||
|
(s) => s.documentId === id,
|
||||||
|
);
|
||||||
|
return Promise.resolve({ processed: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.enqueue(42);
|
||||||
|
await service.processQueue();
|
||||||
|
|
||||||
|
// Beim Verarbeitungsstart war die ID bereits aus der Tabelle entfernt.
|
||||||
|
expect(containedWhileProcessing).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arbeitet eine während der Verarbeitung erneut eingereihte ID erneut ab', async () => {
|
||||||
|
let firstRun = true;
|
||||||
|
processor.processDocumentById.mockImplementation(async (id: number) => {
|
||||||
|
// Beim ersten Lauf feuert der Webhook erneut, während verarbeitet wird.
|
||||||
|
if (firstRun && id === 7) {
|
||||||
|
firstRun = false;
|
||||||
|
await service.enqueue(7);
|
||||||
|
}
|
||||||
|
return { processed: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.enqueue(7);
|
||||||
|
await service.processQueue();
|
||||||
|
|
||||||
|
expect(processor.processDocumentById).toHaveBeenCalledTimes(2);
|
||||||
|
expect(queueRepo.store).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('startet keinen zweiten Durchlauf parallel (isProcessing-Guard)', async () => {
|
||||||
|
let resolveFirst: (() => void) | undefined;
|
||||||
|
let signalStarted!: () => void;
|
||||||
|
const started = new Promise<void>((res) => {
|
||||||
|
signalStarted = res;
|
||||||
|
});
|
||||||
|
processor.processDocumentById.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<{ processed: boolean }>((res) => {
|
||||||
|
resolveFirst = () => res({ processed: true });
|
||||||
|
signalStarted(); // erste Verarbeitung läuft und blockiert hier
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.enqueue(1);
|
||||||
|
const firstRun = service.processQueue(); // blockiert in processDocumentById
|
||||||
|
await started; // warten, bis der erste Lauf tatsächlich verarbeitet
|
||||||
|
await service.processQueue(); // muss sofort zurückkehren (isProcessing)
|
||||||
|
|
||||||
|
expect(processor.processDocumentById).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveFirst?.();
|
||||||
|
await firstRun;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verarbeitet einen Eintrag erst nach der Mindest-Wartezeit', async () => {
|
||||||
|
// Frisch eingereihter Eintrag (Alter ~0) darf noch nicht verarbeitet werden.
|
||||||
|
queueRepo.store.push({ documentId: 99, createdAt: new Date() });
|
||||||
|
|
||||||
|
await service.processQueue();
|
||||||
|
expect(processor.processDocumentById).not.toHaveBeenCalled();
|
||||||
|
expect(queueRepo.store).toHaveLength(1);
|
||||||
|
|
||||||
|
// Nach Überschreiten der Mindest-Wartezeit (5000 ms) wird verarbeitet.
|
||||||
|
queueRepo.store[0].createdAt = new Date(Date.now() - 6000);
|
||||||
|
await service.processQueue();
|
||||||
|
expect(processor.processDocumentById).toHaveBeenCalledWith(99);
|
||||||
|
expect(queueRepo.store).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Interval } from '@nestjs/schedule';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Raw, Repository } from 'typeorm';
|
||||||
|
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
|
||||||
|
import { Setting } from '../database/entities/setting.entity';
|
||||||
|
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
|
||||||
|
|
||||||
|
// Tag (Schlüssel) des Settings-Eintrags, der den letzten Webhook-Aufruf festhält.
|
||||||
|
const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call';
|
||||||
|
|
||||||
|
// Prüfintervall der Warteschlange (sehr kurz). Über ENV überschreibbar.
|
||||||
|
const QUEUE_INTERVAL_MS = Number(process.env.WEBHOOK_QUEUE_INTERVAL_MS) || 1000;
|
||||||
|
|
||||||
|
// Mindest-Verweildauer zwischen Einreihen und Verarbeitung. Ein Eintrag wird
|
||||||
|
// frühestens verarbeitet, wenn er so lange in der Warteschlange lag. Über ENV
|
||||||
|
// überschreibbar (Default 5000 ms).
|
||||||
|
const MIN_QUEUE_AGE_MS = Number(process.env.WEBHOOK_QUEUE_MIN_AGE_MS) || 5000;
|
||||||
|
|
||||||
|
interface WebhookStatusInfo {
|
||||||
|
documentId: number | null;
|
||||||
|
action?: string;
|
||||||
|
status: 'queued' | 'processed' | 'error' | 'bad-request';
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entkoppelt den Webhook-Empfang von der Verarbeitung: Eingehende Dokument-IDs
|
||||||
|
* werden in eine **persistente**, deduplizierte Warteschlange (`webhook_queue`)
|
||||||
|
* gelegt und von einem separaten Intervall-Prozess **sequenziell** (ohne
|
||||||
|
* Überschneidung) abgearbeitet. So führt mehrfaches schnelles Speichern
|
||||||
|
* desselben Dokuments nicht zu parallelen Läufen.
|
||||||
|
*
|
||||||
|
* Verhalten:
|
||||||
|
* - Jede ID kommt nur **einmal** in der Warteschlange vor (Dedup via Primär-
|
||||||
|
* schlüssel `documentId` / `INSERT IGNORE`).
|
||||||
|
* - Beim Verarbeitungsstart wird die ID **sofort** aus der Tabelle entfernt; ein
|
||||||
|
* erneutes Feuern während der Verarbeitung reiht sie wieder ein (ein weiterer
|
||||||
|
* Lauf folgt danach).
|
||||||
|
* - Es läuft immer nur eine Verarbeitung gleichzeitig (`isProcessing`-Guard).
|
||||||
|
* - Zwischen Einreihen und Verarbeitung liegen mindestens `MIN_QUEUE_AGE_MS`
|
||||||
|
* (Default 5000 ms); jüngere Einträge warten bis zum nächsten Tick.
|
||||||
|
*
|
||||||
|
* Da die Warteschlange in der Datenbank liegt, überstehen ausstehende IDs einen
|
||||||
|
* Neustart und werden nach dem Boot weiterverarbeitet.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class WebhookQueueService {
|
||||||
|
private readonly logger = new Logger(WebhookQueueService.name);
|
||||||
|
private isProcessing = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly paperlessProcessor: PaperlessProcessorService,
|
||||||
|
@InjectRepository(Setting)
|
||||||
|
private readonly settingRepo: Repository<Setting>,
|
||||||
|
@InjectRepository(WebhookQueueItem)
|
||||||
|
private readonly queueRepo: Repository<WebhookQueueItem>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Anzahl der aktuell wartenden Dokument-IDs. */
|
||||||
|
count(): Promise<number> {
|
||||||
|
return this.queueRepo.count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiht eine Dokument-ID zur Verarbeitung ein. Ist die ID bereits in der
|
||||||
|
* Warteschlange, wird sie nicht erneut hinzugefügt (Dedup über den Primär-
|
||||||
|
* schlüssel; `INSERT IGNORE` ist atomar und race-sicher).
|
||||||
|
*/
|
||||||
|
async enqueue(documentId: number, action?: string): Promise<void> {
|
||||||
|
const result = await this.queueRepo
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into(WebhookQueueItem)
|
||||||
|
.values({ documentId, action: action ?? null })
|
||||||
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
const added =
|
||||||
|
((result.raw as { affectedRows?: number })?.affectedRows ?? 0) > 0;
|
||||||
|
if (added) {
|
||||||
|
this.logger.log(`Dokument ${documentId} eingereiht.`);
|
||||||
|
} else {
|
||||||
|
this.logger.log(
|
||||||
|
`Dokument ${documentId} ist bereits in der Warteschlange – nicht erneut hinzugefügt.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.recordStatus({ documentId, action, status: 'queued' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft in kurzen Abständen die Warteschlange und arbeitet vorhandene IDs
|
||||||
|
* nacheinander ab. Ein bereits laufender Durchlauf wird nicht doppelt
|
||||||
|
* gestartet (kein paralleles Verarbeiten).
|
||||||
|
*/
|
||||||
|
@Interval(QUEUE_INTERVAL_MS)
|
||||||
|
async processQueue(): Promise<void> {
|
||||||
|
if (this.isProcessing) return;
|
||||||
|
this.isProcessing = true;
|
||||||
|
try {
|
||||||
|
// Solange (alte genug) Einträge vorhanden sind, sequenziell abarbeiten.
|
||||||
|
for (;;) {
|
||||||
|
// Ältesten Eintrag (FIFO) holen, der die Mindest-Wartezeit erfüllt.
|
||||||
|
// Der Altersvergleich läuft bewusst in der DB (NOW() vs. createdAt) und
|
||||||
|
// ist damit unabhängig von Zeitzone/Uhr des Node-Prozesses – ein Node/DB-
|
||||||
|
// Zeitversatz würde sonst den Vergleich verfälschen (Einträge nie "alt
|
||||||
|
// genug"). Nur ein numerisches Konstantenliteral wird interpoliert.
|
||||||
|
const [next] = await this.queueRepo.find({
|
||||||
|
where: {
|
||||||
|
createdAt: Raw(
|
||||||
|
(alias) =>
|
||||||
|
`${alias} <= (NOW(6) - INTERVAL ${MIN_QUEUE_AGE_MS * 1000} MICROSECOND)`,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
order: { createdAt: 'ASC' },
|
||||||
|
take: 1,
|
||||||
|
});
|
||||||
|
if (!next) break; // Warteschlange leer oder noch nichts alt genug
|
||||||
|
// SOFORT (vor Verarbeitungsstart) aus der Tabelle entfernen.
|
||||||
|
await this.queueRepo.delete({ documentId: next.documentId });
|
||||||
|
await this.handleDocument(next.documentId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isProcessing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDocument(documentId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.paperlessProcessor.processDocumentById(documentId);
|
||||||
|
this.logger.log(`Dokument ${documentId} aus Warteschlange verarbeitet.`);
|
||||||
|
await this.recordStatus({ documentId, status: 'processed' });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
this.logger.error(
|
||||||
|
`Fehler bei der Verarbeitung von Dokument ${documentId}: ${message}`,
|
||||||
|
);
|
||||||
|
await this.recordStatus({ documentId, status: 'error', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den letzten festgehaltenen Webhook-Status sowie die aktuelle
|
||||||
|
* Warteschlangen-Größe.
|
||||||
|
*/
|
||||||
|
async getStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
|
||||||
|
const setting = await this.settingRepo.findOneBy({
|
||||||
|
Tag: LAST_WEBHOOK_CALL_TAG,
|
||||||
|
});
|
||||||
|
let lastCall: unknown = null;
|
||||||
|
if (setting?.Wert) {
|
||||||
|
try {
|
||||||
|
lastCall = JSON.parse(setting.Wert);
|
||||||
|
} catch {
|
||||||
|
lastCall = { raw: setting.Wert };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { lastCall, queueSize: await this.queueRepo.count() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hält den letzten Webhook-Vorgang in der Settings-Tabelle fest
|
||||||
|
* (Tag "last_webhook_call"). Fehler beim Speichern werden nur geloggt.
|
||||||
|
*/
|
||||||
|
async recordStatus(info: WebhookStatusInfo): Promise<void> {
|
||||||
|
try {
|
||||||
|
const value = JSON.stringify({
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
documentId: info.documentId,
|
||||||
|
action: info.action ?? null,
|
||||||
|
status: info.status,
|
||||||
|
...(info.reason ? { reason: info.reason } : {}),
|
||||||
|
...(info.message ? { message: info.message.slice(0, 100) } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let setting = await this.settingRepo.findOneBy({
|
||||||
|
Tag: LAST_WEBHOOK_CALL_TAG,
|
||||||
|
});
|
||||||
|
if (!setting) {
|
||||||
|
setting = this.settingRepo.create({
|
||||||
|
Typ: 0,
|
||||||
|
Tag: LAST_WEBHOOK_CALL_TAG,
|
||||||
|
Wert: value,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setting.Wert = value;
|
||||||
|
}
|
||||||
|
await this.settingRepo.save(setting);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(
|
||||||
|
'Konnte Webhook-Status nicht in den Settings speichern',
|
||||||
|
err instanceof Error ? err.stack : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,11 +9,8 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
|
||||||
import { Repository } from 'typeorm';
|
|
||||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||||
import { PaperlessProcessorService } from '../paperless/paperless-processor.service';
|
import { WebhookQueueService } from './webhook-queue.service';
|
||||||
import { Setting } from '../database/entities/setting.entity';
|
|
||||||
|
|
||||||
export interface PaperlessWebhookPayload {
|
export interface PaperlessWebhookPayload {
|
||||||
doc_url?: string;
|
doc_url?: string;
|
||||||
@@ -22,18 +19,11 @@ export interface PaperlessWebhookPayload {
|
|||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tag (Schlüssel) des Settings-Eintrags, der den letzten Webhook-Aufruf festhält.
|
|
||||||
const LAST_WEBHOOK_CALL_TAG = 'last_webhook_call';
|
|
||||||
|
|
||||||
@Controller('api/webhook')
|
@Controller('api/webhook')
|
||||||
export class WebhookController {
|
export class WebhookController {
|
||||||
private readonly logger = new Logger(WebhookController.name);
|
private readonly logger = new Logger(WebhookController.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly webhookQueue: WebhookQueueService) {}
|
||||||
private readonly paperlessProcessor: PaperlessProcessorService,
|
|
||||||
@InjectRepository(Setting)
|
|
||||||
private readonly settingRepo: Repository<Setting>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@UseGuards(ApiKeyGuard)
|
@UseGuards(ApiKeyGuard)
|
||||||
@Post('paperless')
|
@Post('paperless')
|
||||||
@@ -44,7 +34,7 @@ export class WebhookController {
|
|||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
|
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
|
||||||
);
|
);
|
||||||
await this.recordWebhookCall({
|
await this.webhookQueue.recordStatus({
|
||||||
documentId: null,
|
documentId: null,
|
||||||
action: payload.action,
|
action: payload.action,
|
||||||
status: 'bad-request',
|
status: 'bad-request',
|
||||||
@@ -55,54 +45,22 @@ export class WebhookController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Webhook: action=${payload.action}, document=${documentId}`,
|
`Webhook: action=${payload.action}, document=${documentId} → eingereiht`,
|
||||||
);
|
);
|
||||||
|
// Sofort einreihen und antworten; die eigentliche Verarbeitung übernimmt
|
||||||
try {
|
// der separate Queue-Prozess (sequenziell, dedupliziert).
|
||||||
const result =
|
await this.webhookQueue.enqueue(documentId, payload.action);
|
||||||
await this.paperlessProcessor.processDocumentById(documentId);
|
return { status: 'queued', documentId };
|
||||||
const status = result.processed ? 'processed' : 'skipped';
|
|
||||||
await this.recordWebhookCall({
|
|
||||||
documentId,
|
|
||||||
action: payload.action,
|
|
||||||
status,
|
|
||||||
reason: result.reason,
|
|
||||||
});
|
|
||||||
return { status, reason: result.reason };
|
|
||||||
} catch (err) {
|
|
||||||
// Fehler tolerieren (wie der bisherige Cron) und 200 zurückgeben,
|
|
||||||
// damit Paperless keine Retry-Schleife startet.
|
|
||||||
const message = err instanceof Error ? err.message : String(err);
|
|
||||||
this.logger.error(
|
|
||||||
`Fehler bei Webhook-Verarbeitung von Dokument ${documentId}: ${message}`,
|
|
||||||
);
|
|
||||||
await this.recordWebhookCall({
|
|
||||||
documentId,
|
|
||||||
action: payload.action,
|
|
||||||
status: 'error',
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
return { status: 'error', message };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liefert Informationen zum letzten Webhook-Aufruf (Zeitpunkt, Dokument,
|
* Liefert Informationen zum letzten Webhook-Vorgang (Zeitpunkt, Dokument,
|
||||||
* Ergebnis). Über die globalen Guards per JWT oder API-Key zugänglich.
|
* Ergebnis) und die aktuelle Warteschlangen-Größe. Über die globalen Guards
|
||||||
|
* per JWT oder API-Key zugänglich.
|
||||||
*/
|
*/
|
||||||
@Get('status')
|
@Get('status')
|
||||||
async getWebhookStatus(): Promise<{ lastCall: unknown }> {
|
async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
|
||||||
const setting = await this.settingRepo.findOneBy({
|
return this.webhookQueue.getStatus();
|
||||||
Tag: LAST_WEBHOOK_CALL_TAG,
|
|
||||||
});
|
|
||||||
if (!setting?.Wert) {
|
|
||||||
return { lastCall: null };
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
return { lastCall: JSON.parse(setting.Wert) };
|
|
||||||
} catch {
|
|
||||||
return { lastCall: { raw: setting.Wert } };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,47 +82,4 @@ export class WebhookController {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hält den letzten Webhook-Aufruf in der Settings-Tabelle fest
|
|
||||||
* (Tag "last_webhook_call"). Fehler beim Speichern werden nur geloggt und
|
|
||||||
* beeinflussen die Webhook-Antwort nicht.
|
|
||||||
*/
|
|
||||||
private async recordWebhookCall(info: {
|
|
||||||
documentId: number | null;
|
|
||||||
action?: string;
|
|
||||||
status: string;
|
|
||||||
reason?: string;
|
|
||||||
message?: string;
|
|
||||||
}): Promise<void> {
|
|
||||||
try {
|
|
||||||
const value = JSON.stringify({
|
|
||||||
at: new Date().toISOString(),
|
|
||||||
documentId: info.documentId,
|
|
||||||
action: info.action ?? null,
|
|
||||||
status: info.status,
|
|
||||||
...(info.reason ? { reason: info.reason } : {}),
|
|
||||||
...(info.message ? { message: info.message.slice(0, 100) } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
let setting = await this.settingRepo.findOneBy({
|
|
||||||
Tag: LAST_WEBHOOK_CALL_TAG,
|
|
||||||
});
|
|
||||||
if (!setting) {
|
|
||||||
setting = this.settingRepo.create({
|
|
||||||
Typ: 0,
|
|
||||||
Tag: LAST_WEBHOOK_CALL_TAG,
|
|
||||||
Wert: value,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setting.Wert = value;
|
|
||||||
}
|
|
||||||
await this.settingRepo.save(setting);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(
|
|
||||||
'Konnte letzten Webhook-Aufruf nicht in den Settings speichern',
|
|
||||||
err instanceof Error ? err.stack : String(err),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { WebhookController } from './webhook.controller';
|
import { WebhookController } from './webhook.controller';
|
||||||
|
import { WebhookQueueService } from './webhook-queue.service';
|
||||||
import { PaperlessModule } from '../paperless/paperless.module';
|
import { PaperlessModule } from '../paperless/paperless.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { Setting } from '../database/entities/setting.entity';
|
import { Setting } from '../database/entities/setting.entity';
|
||||||
|
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Setting]), PaperlessModule, AuthModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Setting, WebhookQueueItem]),
|
||||||
|
PaperlessModule,
|
||||||
|
AuthModule,
|
||||||
|
],
|
||||||
controllers: [WebhookController],
|
controllers: [WebhookController],
|
||||||
|
providers: [WebhookQueueService],
|
||||||
})
|
})
|
||||||
export class WebhookModule {}
|
export class WebhookModule {}
|
||||||
|
|||||||
@@ -22,10 +22,7 @@ export class ZahlungController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put('documents/:id/zahlung')
|
@Put('documents/:id/zahlung')
|
||||||
setZahlung(
|
setZahlung(@Param('id') id: string, @Body('value') value: string | null) {
|
||||||
@Param('id') id: string,
|
|
||||||
@Body('value') value: string | null,
|
|
||||||
) {
|
|
||||||
return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null);
|
return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ export class ZahlungService {
|
|||||||
private readonly paperlessService: PaperlessService,
|
private readonly paperlessService: PaperlessService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getZahlungDocuments(page: number, pageSize: number, filter: ZahlungFilter) {
|
async getZahlungDocuments(
|
||||||
|
page: number,
|
||||||
|
pageSize: number,
|
||||||
|
filter: ZahlungFilter,
|
||||||
|
) {
|
||||||
const docTypes = await this.documentTypeRepo.find({
|
const docTypes = await this.documentTypeRepo.find({
|
||||||
where: { FreigabeErforderlich: true as any },
|
where: { FreigabeErforderlich: true as any },
|
||||||
});
|
});
|
||||||
@@ -44,7 +48,10 @@ export class ZahlungService {
|
|||||||
const result = await this.paperlessService.getDocuments(params);
|
const result = await this.paperlessService.getDocuments(params);
|
||||||
allDocs = result.results ?? [];
|
allDocs = result.results ?? [];
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn('Fehler beim Laden der Belege für Zahlung', err?.message);
|
this.logger.warn(
|
||||||
|
'Fehler beim Laden der Belege für Zahlung',
|
||||||
|
err?.message,
|
||||||
|
);
|
||||||
return { count: 0, results: [] };
|
return { count: 0, results: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +80,12 @@ export class ZahlungService {
|
|||||||
|
|
||||||
private getCfValue(doc: any, fieldId: number): string | null {
|
private getCfValue(doc: any, fieldId: number): string | null {
|
||||||
const cf = (doc.custom_fields ?? []).find((f: any) => f.field === fieldId);
|
const cf = (doc.custom_fields ?? []).find((f: any) => f.field === fieldId);
|
||||||
if (!cf || cf.value === null || cf.value === undefined || cf.value === '') return null;
|
if (!cf || cf.value === null || cf.value === undefined || cf.value === '')
|
||||||
|
return null;
|
||||||
if (typeof cf.value === 'object') {
|
if (typeof cf.value === 'object') {
|
||||||
return String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null;
|
return (
|
||||||
|
String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return String(cf.value);
|
return String(cf.value);
|
||||||
}
|
}
|
||||||
@@ -91,20 +101,24 @@ export class ZahlungService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const customFields: any[] = [...(doc.custom_fields ?? [])];
|
const customFields: any[] = [...(doc.custom_fields ?? [])];
|
||||||
const existing = customFields.find((f: any) => f.field === ZAHLUNG_FIELD_ID);
|
const existing = customFields.find(
|
||||||
|
(f: any) => f.field === ZAHLUNG_FIELD_ID,
|
||||||
|
);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.value = value;
|
existing.value = value;
|
||||||
} else if (value !== null && value !== '') {
|
} else if (value !== null && value !== '') {
|
||||||
customFields.push({ field: ZAHLUNG_FIELD_ID, value });
|
customFields.push({ field: ZAHLUNG_FIELD_ID, value });
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.paperlessService.updateDocument(documentId, { custom_fields: customFields });
|
await this.paperlessService.updateDocument(documentId, {
|
||||||
|
custom_fields: customFields,
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getZahlungOptions(): Promise<{ id: string; label: string }[]> {
|
async getZahlungOptions(): Promise<{ id: string; label: string }[]> {
|
||||||
const fields = await this.paperlessService.getCustomFields();
|
const fields = await this.paperlessService.getCustomFields();
|
||||||
const field = (fields as any[]).find((f: any) => f.id === ZAHLUNG_FIELD_ID);
|
const field = fields.find((f: any) => f.id === ZAHLUNG_FIELD_ID);
|
||||||
if (!field) return [];
|
if (!field) return [];
|
||||||
|
|
||||||
const rawOptions: any[] = field.extra_data?.select_options ?? [];
|
const rawOptions: any[] = field.extra_data?.select_options ?? [];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { saveReturnUrl } from './auth/sessionRedirect';
|
|||||||
import { ThemeProvider, useTheme } from './theme/ThemeContext';
|
import { ThemeProvider, useTheme } from './theme/ThemeContext';
|
||||||
import AuthCallback from './auth/AuthCallback';
|
import AuthCallback from './auth/AuthCallback';
|
||||||
import AppLayout from './layouts/AppLayout';
|
import AppLayout from './layouts/AppLayout';
|
||||||
|
import AppErrorBoundary from './components/AppErrorBoundary';
|
||||||
import { Spin, Result, Button } from 'antd';
|
import { Spin, Result, Button } from 'antd';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
@@ -76,6 +77,10 @@ function ThemedApp() {
|
|||||||
token: {
|
token: {
|
||||||
colorPrimary: '#1677ff',
|
colorPrimary: '#1677ff',
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
|
// Gleiche Schriftfamilie wie der Body (index.css), damit AntD-Eingaben
|
||||||
|
// – z.B. das Select-Suchfeld – nicht im AntD-Default-Stack abweichen.
|
||||||
|
fontFamily:
|
||||||
|
"'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif",
|
||||||
...(isDark
|
...(isDark
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
@@ -113,6 +118,7 @@ function ThemedApp() {
|
|||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AntdApp>
|
<AntdApp>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<AppErrorBoundary>
|
||||||
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}><Spin size="large" /></div>}>
|
<Suspense fallback={<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}><Spin size="large" /></div>}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
@@ -139,6 +145,7 @@ function ThemedApp() {
|
|||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
</AppErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AntdApp>
|
</AntdApp>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import axios from 'axios';
|
import axios, { type InternalAxiosRequestConfig } from 'axios';
|
||||||
import { getAccessToken } from '../auth/oidc';
|
import { getAccessToken, renewToken } from '../auth/oidc';
|
||||||
import { triggerLoginRedirect } from '../auth/sessionRedirect';
|
import { triggerLoginRedirect } from '../auth/sessionRedirect';
|
||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
|
|
||||||
|
// Markiert eine bereits einmal wiederholte Anfrage, um Endlosschleifen zu
|
||||||
|
// vermeiden.
|
||||||
|
type RetryConfig = InternalAxiosRequestConfig & { _retried?: boolean };
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: getEnv('VITE_API_URL') || '',
|
baseURL: getEnv('VITE_API_URL') || '',
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
@@ -19,7 +23,18 @@ api.interceptors.request.use(async (config) => {
|
|||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
async (error) => {
|
async (error) => {
|
||||||
if (error.response?.status === 401) {
|
const original = error.config as RetryConfig | undefined;
|
||||||
|
if (error.response?.status === 401 && original && !original._retried) {
|
||||||
|
original._retried = true;
|
||||||
|
// Erst still erneuern und die Anfrage einmal wiederholen, bevor wir den
|
||||||
|
// Nutzer zu einer kompletten Neuanmeldung zwingen.
|
||||||
|
const user = await renewToken();
|
||||||
|
if (user?.access_token) {
|
||||||
|
original.headers.Authorization = `Bearer ${user.access_token}`;
|
||||||
|
return api(original);
|
||||||
|
}
|
||||||
|
// Erneuerung fehlgeschlagen (Refresh-Token ungültig/abgelaufen) →
|
||||||
|
// echte Neuanmeldung nötig.
|
||||||
await triggerLoginRedirect();
|
await triggerLoginRedirect();
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ export interface FreigabeDocument {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
created: string;
|
created: string;
|
||||||
created_date: string;
|
|
||||||
correspondent: number | null;
|
correspondent: number | null;
|
||||||
document_type: number | null;
|
document_type: number | null;
|
||||||
archive_serial_number: number | null;
|
archive_serial_number: number | null;
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ export interface AgrarmonitorPollingConfig {
|
|||||||
tagHochgeladen: string;
|
tagHochgeladen: string;
|
||||||
linkField: string;
|
linkField: string;
|
||||||
tagManuell: string;
|
tagManuell: string;
|
||||||
|
importWartezeitMinuten: string;
|
||||||
|
notizMarker: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgrarmonitorPollingResult {
|
export interface AgrarmonitorPollingResult {
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ export interface WebhookLastCall {
|
|||||||
at: string; // ISO-Zeitstempel
|
at: string; // ISO-Zeitstempel
|
||||||
documentId: number | null;
|
documentId: number | null;
|
||||||
action: string | null;
|
action: string | null;
|
||||||
status: 'processed' | 'skipped' | 'error' | 'bad-request' | string;
|
status: 'queued' | 'processed' | 'error' | 'bad-request' | string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebhookStatus {
|
export interface WebhookStatus {
|
||||||
lastCall: WebhookLastCall | null;
|
lastCall: WebhookLastCall | null;
|
||||||
|
queueSize?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const webhookApi = {
|
export const webhookApi = {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export interface ZahlungDocument {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
created: string;
|
created: string;
|
||||||
created_date: string;
|
|
||||||
correspondent: number | null;
|
correspondent: number | null;
|
||||||
document_type: number | null;
|
document_type: number | null;
|
||||||
archive_serial_number: number | null;
|
archive_serial_number: number | null;
|
||||||
|
|||||||
@@ -34,9 +34,36 @@ export async function getUser(): Promise<User | null> {
|
|||||||
return userManager.getUser();
|
return userManager.getUser();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Laufender Silent-Renew-Vorgang – mehrere gleichzeitige Aufrufe teilen sich
|
||||||
|
// denselben Refresh (Dedup), damit nicht parallele signinSilent-Aufrufe mit
|
||||||
|
// einem rotierenden Refresh-Token kollidieren (invalid_grant).
|
||||||
|
let renewPromise: Promise<User | null> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erneuert das Token still über den Refresh-Token (signinSilent). Schlägt die
|
||||||
|
* Erneuerung fehl, wird null geliefert (statt zu werfen). Gleichzeitige Aufrufe
|
||||||
|
* werden dedupliziert.
|
||||||
|
*/
|
||||||
|
export function renewToken(): Promise<User | null> {
|
||||||
|
if (!renewPromise) {
|
||||||
|
renewPromise = userManager.signinSilent().catch((err: unknown) => {
|
||||||
|
console.error('OIDC: signinSilent fehlgeschlagen', err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
void renewPromise.finally(() => {
|
||||||
|
renewPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return renewPromise;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAccessToken(): Promise<string | null> {
|
export async function getAccessToken(): Promise<string | null> {
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
return user?.access_token ?? null;
|
if (!user) return null; // nicht eingeloggt
|
||||||
|
if (!user.expired) return user.access_token ?? null;
|
||||||
|
// Token ist abgelaufen → vor dem Senden still erneuern.
|
||||||
|
const renewed = await renewToken();
|
||||||
|
return renewed?.access_token ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export { userManager };
|
export { userManager };
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Component } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Erkennt fehlgeschlagene Lazy-Chunk-Imports:
|
||||||
|
// Safari: "Importing a module script failed."
|
||||||
|
// Chrome: "Failed to fetch dynamically imported module: …"
|
||||||
|
// Firefox: "error loading dynamically imported module"
|
||||||
|
function istChunkLadefehler(error: Error): boolean {
|
||||||
|
return /module script failed|dynamically imported module/i.test(error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fängt Renderfehler ab, die sonst den kompletten React-Baum aushängen und
|
||||||
|
* nur eine weiße Seite hinterlassen — insbesondere fehlgeschlagene
|
||||||
|
* React.lazy-Chunk-Imports nach einem Deployment oder bei beschädigtem
|
||||||
|
* Browser-Cache (Assets werden mit "immutable" gecacht und nie revalidiert).
|
||||||
|
*/
|
||||||
|
export default class AppErrorBoundary extends Component<Props, State> {
|
||||||
|
state: State = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error) {
|
||||||
|
console.error('AppErrorBoundary:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const { error } = this.state;
|
||||||
|
if (!error) return this.props.children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status="error"
|
||||||
|
title="Die Seite konnte nicht geladen werden"
|
||||||
|
subTitle={
|
||||||
|
istChunkLadefehler(error)
|
||||||
|
? 'Ein Teil der Anwendung konnte nicht nachgeladen werden — vermutlich wurde die ' +
|
||||||
|
'Anwendung aktualisiert oder eine zwischengespeicherte Datei ist beschädigt. ' +
|
||||||
|
'Bitte neu laden. Hilft das nicht, den Browser-Cache leeren ' +
|
||||||
|
'(Safari: Verlauf → Websitedaten löschen).'
|
||||||
|
: error.message
|
||||||
|
}
|
||||||
|
extra={
|
||||||
|
<Button type="primary" onClick={() => window.location.reload()}>
|
||||||
|
Seite neu laden
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd';
|
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider, Tag } from 'antd';
|
||||||
import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EyeOutlined, SearchOutlined, ExportOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { posteingangApi } from '../api/posteingang';
|
import { posteingangApi } from '../api/posteingang';
|
||||||
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
|
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
|
||||||
@@ -11,6 +11,7 @@ import type { PaperlessDocType, PaperlessCorrespondent, PaperlessTag } from '../
|
|||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
|
import { AuthIframe, openAuthUrl } from '../utils/auth-resource';
|
||||||
import DocumentSearchModal from './DocumentSearchModal';
|
import DocumentSearchModal from './DocumentSearchModal';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ interface Props {
|
|||||||
|
|
||||||
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
|
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -292,28 +294,51 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (isNext: boolean = false) => {
|
const runSave = async (values: any, isNext: boolean) => {
|
||||||
try {
|
// Kontonummer-Logik (unverändert): neue Kontonummer ggf. bestätigen lassen.
|
||||||
const values = await form.validateFields();
|
|
||||||
|
|
||||||
// Kontonummer Logic
|
|
||||||
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
|
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
|
||||||
if (kontonummerReq && values[`cf_5`] && values.correspondent) {
|
if (kontonummerReq && values['cf_5'] && values.correspondent) {
|
||||||
const kNummer = values[`cf_5`];
|
const kNummer = values['cf_5'];
|
||||||
const knData = await posteingangApi.getKontonummern(values.correspondent);
|
const knData = await posteingangApi.getKontonummern(values.correspondent);
|
||||||
const exists = knData.some(k => k.Nummer === kNummer);
|
const exists = knData.some(k => k.Nummer === kNummer);
|
||||||
|
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
// Prompt user to save kontonummer
|
// Prompt user to save kontonummer
|
||||||
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
|
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
|
||||||
return; // Stop saving, wait for confirmation
|
return; // Stop saving, wait for confirmation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleSaveDocument(values, isNext);
|
await handleSaveDocument(values, isNext);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (isNext: boolean = false) => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
await runSave(values, isNext);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Validation failed
|
const errorFields =
|
||||||
|
(e as { errorFields?: { name: unknown; errors: string[] }[] })?.errorFields ?? [];
|
||||||
|
|
||||||
|
// Kein Validierungsfehler, oder Posteingang → bisheriges Verhalten (stiller Abbruch).
|
||||||
|
if (errorFields.length === 0 || isPosteingang) {
|
||||||
console.error("Form validation failed:", e);
|
console.error("Form validation failed:", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Manuell bearbeiten": Speichern trotz fehlender Pflichtfelder mit Hinweis anbieten.
|
||||||
|
const missing = Array.from(new Set(errorFields.flatMap((f) => f.errors)));
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Pflichtfelder unvollständig',
|
||||||
|
okText: 'Trotzdem speichern',
|
||||||
|
cancelText: 'Abbrechen',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>Nicht alle Pflichtfelder sind ausgefüllt:</p>
|
||||||
|
<ul>{missing.map((m, i) => <li key={i}>{m}</li>)}</ul>
|
||||||
|
<p>Möchtest du trotzdem speichern?</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
onOk: () => runSave(form.getFieldsValue(), isNext),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -349,8 +374,8 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
|||||||
title={`Dokument bearbeiten (${document?.title || ''})`}
|
title={`Dokument bearbeiten (${document?.title || ''})`}
|
||||||
open={open && !kontonummerMissing}
|
open={open && !kontonummerMissing}
|
||||||
onCancel={() => onClose(false)}
|
onCancel={() => onClose(false)}
|
||||||
width={1400}
|
width={{ xs: '100vw', md: 900, xl: 1400 }}
|
||||||
style={{ top: 20 }}
|
style={isMobile ? {} : { top: 20 }}
|
||||||
footer={
|
footer={
|
||||||
hasNextDocument ? [
|
hasNextDocument ? [
|
||||||
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
|
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
|
||||||
@@ -363,8 +388,12 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Spin spinning={loading}>
|
<Spin spinning={loading}>
|
||||||
<Row gutter={16} style={{ height: '75vh', overflow: 'hidden' }}>
|
<Row gutter={[16, 16]} style={isMobile ? {} : { height: '75vh', overflow: 'hidden' }}>
|
||||||
<Col span={10} style={{ overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}>
|
<Col
|
||||||
|
xs={24}
|
||||||
|
lg={10}
|
||||||
|
style={isMobile ? {} : { overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}
|
||||||
|
>
|
||||||
<Form form={form} layout="vertical" disabled={saving}>
|
<Form form={form} layout="vertical" disabled={saving}>
|
||||||
|
|
||||||
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
|
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
|
||||||
@@ -556,10 +585,20 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
|||||||
})}
|
})}
|
||||||
</Form>
|
</Form>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={14} style={{ height: '100%' }}>
|
<Col xs={24} lg={14} style={isMobile ? {} : { height: '100%' }}>
|
||||||
|
{isMobile && (
|
||||||
|
<Button
|
||||||
|
icon={<ExportOutlined />}
|
||||||
|
block
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
onClick={() => openAuthUrl(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}`)}
|
||||||
|
>
|
||||||
|
PDF in neuem Tab öffnen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<AuthIframe
|
<AuthIframe
|
||||||
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
|
||||||
style={{ width: '100%', height: '100%' }}
|
style={{ width: '100%', height: isMobile ? '50vh' : '100%' }}
|
||||||
title="PDF Preview"
|
title="PDF Preview"
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
|
|||||||
open={open}
|
open={open}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
footer={null}
|
footer={null}
|
||||||
width={800}
|
width={{ xs: '100vw', md: 800 }}
|
||||||
style={{ top: 50 }}
|
style={{ top: 50 }}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
@@ -100,7 +100,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
|
|||||||
description={
|
description={
|
||||||
<Space direction="vertical" size={0}>
|
<Space direction="vertical" size={0}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>ID: {doc.id} | ASN: {doc.archive_serial_number || 'Keine'}</Text>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>Erstellt: {dayjs(doc.created_date).format('DD.MM.YYYY')}</Text>
|
<Text type="secondary" style={{ fontSize: 12 }}>Erstellt: {dayjs(doc.created).format('DD.MM.YYYY')}</Text>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -538,8 +538,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
{toProcess.map(item => (
|
{toProcess.map(item => (
|
||||||
<div key={item.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
<div key={item.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||||
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{item.fileName}</Text>
|
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{item.fileName}</Text>
|
||||||
<Row gutter={24}>
|
<Row gutter={[24, 16]}>
|
||||||
<Col span={8}>
|
<Col xs={24} lg={8}>
|
||||||
{/* Eingangsdatum */}
|
{/* Eingangsdatum */}
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text>
|
<Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text>
|
||||||
@@ -626,7 +626,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={16}>
|
<Col xs={24} lg={16}>
|
||||||
<BarcodePositioner
|
<BarcodePositioner
|
||||||
attachmentId={item.attachmentId}
|
attachmentId={item.attachmentId}
|
||||||
startPage={item.pages?.start}
|
startPage={item.pages?.start}
|
||||||
@@ -666,9 +666,9 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
return (
|
return (
|
||||||
<div key={main.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
<div key={main.virtualId} style={{ marginBottom: 24, padding: 16, border: '1px solid #f0f0f0', borderRadius: 8 }}>
|
||||||
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{main.fileName}</Text>
|
<Text strong style={{ fontSize: 16, marginBottom: 12, display: 'block' }}>{main.fileName}</Text>
|
||||||
<Row gutter={24} align="middle">
|
<Row gutter={[24, 12]} align="middle">
|
||||||
<Col span={showPrint ? 20 : 24}>
|
<Col xs={24} md={showPrint ? 20 : 24}>
|
||||||
<Space size={24}>
|
<Space size={24} wrap>
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
Eingangsdatum: <Text strong>{datum?.format('DD.MM.YYYY') ?? '—'}</Text>
|
Eingangsdatum: <Text strong>{datum?.format('DD.MM.YYYY') ?? '—'}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
@@ -690,7 +690,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
{showPrint && (
|
{showPrint && (
|
||||||
<Col span={4} style={{ textAlign: 'right' }}>
|
<Col xs={24} md={4} style={{ textAlign: 'right' }}>
|
||||||
<Button icon={<PrinterOutlined />} onClick={() => printDocument(main.virtualId, main.attachmentId)}>
|
<Button icon={<PrinterOutlined />} onClick={() => printDocument(main.virtualId, main.attachmentId)}>
|
||||||
Drucken
|
Drucken
|
||||||
</Button>
|
</Button>
|
||||||
@@ -735,7 +735,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
|||||||
title="Paperless Import-Wizard"
|
title="Paperless Import-Wizard"
|
||||||
open={visible}
|
open={visible}
|
||||||
onCancel={onClose}
|
onCancel={onClose}
|
||||||
width={1000}
|
width={{ xs: '100vw', md: 900, lg: 1000 }}
|
||||||
footer={
|
footer={
|
||||||
importSuccess ? (
|
importSuccess ? (
|
||||||
<Button type="primary" onClick={onSuccess ?? onClose}>Schließen</Button>
|
<Button type="primary" onClick={onSuccess ?? onClose}>Schließen</Button>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { List, Card } from 'antd';
|
||||||
|
import type { TablePaginationConfig } from 'antd';
|
||||||
|
|
||||||
|
interface MobileCardListProps<T> {
|
||||||
|
dataSource: T[];
|
||||||
|
rowKey: keyof T | ((record: T) => React.Key);
|
||||||
|
loading?: boolean;
|
||||||
|
/** Gleiche Pagination-Config wie bei <Table> — wird durchgereicht (mobil kompakt). */
|
||||||
|
pagination?: TablePaginationConfig | false;
|
||||||
|
/** Seitenspezifischer Karteninhalt für einen Datensatz. */
|
||||||
|
renderCard: (record: T) => ReactNode;
|
||||||
|
onCardClick?: (record: T) => void;
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile Karten-Ansicht als Ersatz für breite Tabellen:
|
||||||
|
* eine Karte pro Datensatz, Pagination wie bei der Tabelle.
|
||||||
|
*/
|
||||||
|
export default function MobileCardList<T>({
|
||||||
|
dataSource,
|
||||||
|
rowKey,
|
||||||
|
loading,
|
||||||
|
pagination,
|
||||||
|
renderCard,
|
||||||
|
onCardClick,
|
||||||
|
emptyText = 'Keine Einträge vorhanden',
|
||||||
|
}: MobileCardListProps<T>) {
|
||||||
|
const getKey = (record: T): React.Key =>
|
||||||
|
typeof rowKey === 'function' ? rowKey(record) : (record[rowKey] as React.Key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<List
|
||||||
|
dataSource={dataSource}
|
||||||
|
loading={loading}
|
||||||
|
rowKey={getKey}
|
||||||
|
locale={{ emptyText }}
|
||||||
|
pagination={
|
||||||
|
pagination
|
||||||
|
? {
|
||||||
|
...pagination,
|
||||||
|
position: undefined,
|
||||||
|
simple: true,
|
||||||
|
showSizeChanger: false,
|
||||||
|
}
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
renderItem={(record) => (
|
||||||
|
<List.Item style={{ padding: 0, marginBottom: 8, borderBlockEnd: 'none' }}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
styles={{ body: { padding: 12 } }}
|
||||||
|
hoverable={!!onCardClick}
|
||||||
|
onClick={onCardClick ? () => onCardClick(record) : undefined}
|
||||||
|
>
|
||||||
|
{renderCard(record)}
|
||||||
|
</Card>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Grid } from 'antd';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zentraler Breakpoint-Hook: mobil = Viewport unter `md` (768px).
|
||||||
|
* Beim allerersten Render liefert useBreakpoint() noch keine Werte —
|
||||||
|
* dann Desktop annehmen, um ein Layout-Flackern zu vermeiden.
|
||||||
|
*/
|
||||||
|
export function useIsMobile(): boolean {
|
||||||
|
const screens = Grid.useBreakpoint();
|
||||||
|
return screens.md === undefined ? false : !screens.md;
|
||||||
|
}
|
||||||
@@ -139,3 +139,29 @@ body {
|
|||||||
.ant-picker-input > input {
|
.ant-picker-input > input {
|
||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Responsive / Mobile ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.ant-modal {
|
||||||
|
top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal-Inhalte scrollbar statt abgeschnitten (v. a. Settings-Dialoge) */
|
||||||
|
.ant-modal .ant-modal-body {
|
||||||
|
max-height: calc(100dvh - 160px);
|
||||||
|
overflow-y: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sicherheitsnetz: Tabellen ohne Karten-Ansicht seitlich scrollbar */
|
||||||
|
.ant-table-content,
|
||||||
|
.ant-table-body {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge } from 'antd';
|
import { Layout, Menu, Avatar, Dropdown, theme, Typography, Tooltip, Badge, Drawer, Button } from 'antd';
|
||||||
import {
|
import {
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
FileTextOutlined,
|
FileTextOutlined,
|
||||||
@@ -15,9 +15,11 @@ import {
|
|||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
EuroOutlined,
|
EuroOutlined,
|
||||||
|
MenuOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useTheme } from '../theme/ThemeContext';
|
import { useTheme } from '../theme/ThemeContext';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
import { Permission } from '../auth/permissions';
|
import { Permission } from '../auth/permissions';
|
||||||
import { statsApi, type StatsCounts } from '../api/stats';
|
import { statsApi, type StatsCounts } from '../api/stats';
|
||||||
|
|
||||||
@@ -47,14 +49,19 @@ const allMenuItems: MenuItemDef[] = [
|
|||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout, hasPermission, isAuthenticated } = useAuth();
|
const { user, logout, hasPermission, isAuthenticated } = useAuth();
|
||||||
const { token: themeToken } = theme.useToken();
|
const { token: themeToken } = theme.useToken();
|
||||||
const { isDark, toggleTheme } = useTheme();
|
const { isDark, toggleTheme } = useTheme();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const [counts, setCounts] = useState<StatsCounts | null>(null);
|
const [counts, setCounts] = useState<StatsCounts | null>(null);
|
||||||
|
|
||||||
|
// Im Drawer (mobil) ist die Navigation nie eingeklappt
|
||||||
|
const effectiveCollapsed = isMobile ? false : collapsed;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
@@ -80,7 +87,7 @@ export default function AppLayout() {
|
|||||||
label: (
|
label: (
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}>
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
{item.countKey && counts && counts[item.countKey] > 0 && !collapsed && (
|
{item.countKey && counts && counts[item.countKey] > 0 && !effectiveCollapsed && (
|
||||||
<Badge
|
<Badge
|
||||||
count={counts[item.countKey]}
|
count={counts[item.countKey]}
|
||||||
overflowCount={99}
|
overflowCount={99}
|
||||||
@@ -107,9 +114,194 @@ export default function AppLayout() {
|
|||||||
|
|
||||||
const logoColor = isDark ? '#fff' : '#1a1a2e';
|
const logoColor = isDark ? '#fff' : '#1a1a2e';
|
||||||
const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a';
|
const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a';
|
||||||
|
const dividerColor = isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea';
|
||||||
|
|
||||||
|
// Sidebar-Inhalt (Logo, Menü, Bottom-Sektion) — identisch für Sider (Desktop)
|
||||||
|
// und Drawer (mobil). `onNavigate` schließt auf Mobil den Drawer.
|
||||||
|
const renderSidebarContent = (isCollapsed: boolean, onNavigate?: () => void) => (
|
||||||
|
<>
|
||||||
|
{/* Logo / Collapse-Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => (onNavigate ? onNavigate() : setCollapsed(!collapsed))}
|
||||||
|
style={{
|
||||||
|
height: 56,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
userSelect: 'none',
|
||||||
|
border: 'none',
|
||||||
|
borderBottom: `1px solid ${dividerColor}`,
|
||||||
|
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: isCollapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
|
||||||
|
{isCollapsed ? '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);
|
||||||
|
}
|
||||||
|
onNavigate?.();
|
||||||
|
}}
|
||||||
|
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 ${dividerColor}`,
|
||||||
|
padding: isCollapsed ? '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: isCollapsed ? '8px 0' : '8px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: subtleColor,
|
||||||
|
justifyContent: isCollapsed ? '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 }} />}
|
||||||
|
{!isCollapsed && <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');
|
||||||
|
onNavigate?.();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'logout',
|
||||||
|
icon: <LogoutOutlined />,
|
||||||
|
label: 'Abmelden',
|
||||||
|
onClick: () => logout(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
placement="topRight"
|
||||||
|
trigger={['click']}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 10,
|
||||||
|
padding: isCollapsed ? '8px 0' : '8px 12px',
|
||||||
|
borderRadius: 6,
|
||||||
|
cursor: 'pointer',
|
||||||
|
justifyContent: isCollapsed ? '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 />} />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
|
||||||
|
{user?.profile?.name || 'Benutzer'}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ minHeight: '100vh' }}>
|
<Layout style={{ minHeight: '100vh' }}>
|
||||||
|
{isMobile ? (
|
||||||
|
<>
|
||||||
|
{/* Mobile Top-Bar mit Hamburger */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 48,
|
||||||
|
zIndex: 100,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
padding: '0 8px',
|
||||||
|
background: themeToken.colorBgContainer,
|
||||||
|
borderBottom: `1px solid ${dividerColor}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MenuOutlined />}
|
||||||
|
aria-label="Menü öffnen"
|
||||||
|
onClick={() => setDrawerOpen(true)}
|
||||||
|
/>
|
||||||
|
<Text strong style={{ color: logoColor, fontSize: 18 }}>Paperless</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Drawer
|
||||||
|
placement="left"
|
||||||
|
width={260}
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={() => setDrawerOpen(false)}
|
||||||
|
closable={false}
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
position: 'relative',
|
||||||
|
background: isDark ? '#001529' : '#f0f2f7',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderSidebarContent(false, () => setDrawerOpen(false))}
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<Sider
|
<Sider
|
||||||
width={240}
|
width={240}
|
||||||
trigger={null}
|
trigger={null}
|
||||||
@@ -128,137 +320,25 @@ export default function AppLayout() {
|
|||||||
...siderStyle,
|
...siderStyle,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Logo / Collapse-Toggle */}
|
{renderSidebarContent(collapsed)}
|
||||||
<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>
|
</Sider>
|
||||||
|
)}
|
||||||
|
|
||||||
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
|
<Layout
|
||||||
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8 }}>
|
style={{
|
||||||
|
marginLeft: isMobile ? 0 : collapsed ? 80 : 240,
|
||||||
|
marginTop: isMobile ? 48 : 0,
|
||||||
|
transition: 'margin-left 0.2s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Content
|
||||||
|
style={{
|
||||||
|
margin: isMobile ? 8 : 24,
|
||||||
|
padding: isMobile ? 12 : 24,
|
||||||
|
background: themeToken.colorBgContainer,
|
||||||
|
borderRadius: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</Content>
|
</Content>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -3,6 +3,22 @@ import { createRoot } from 'react-dom/client';
|
|||||||
import App from './App';
|
import App from './App';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
// Nach einem Deployment existieren die alten, inhalts-gehashten Chunks nicht
|
||||||
|
// mehr; zudem bleibt ein einmal fehlerhaft zwischengespeicherter Chunk wegen
|
||||||
|
// "Cache-Control: immutable" dauerhaft defekt. Vite meldet fehlgeschlagene
|
||||||
|
// dynamische Imports als 'vite:preloadError' — dann automatisch neu laden,
|
||||||
|
// damit der Browser die aktuelle index.html samt gültiger Chunk-Namen holt.
|
||||||
|
// Höchstens ein Versuch pro Minute, sonst droht eine Reload-Schleife; danach
|
||||||
|
// zeigt der AppErrorBoundary eine sichtbare Fehlermeldung.
|
||||||
|
window.addEventListener('vite:preloadError', (event) => {
|
||||||
|
const KEY = 'vite-preload-error-reload';
|
||||||
|
const letzterVersuch = Number(sessionStorage.getItem(KEY) ?? 0);
|
||||||
|
if (Date.now() - letzterVersuch < 60_000) return;
|
||||||
|
sessionStorage.setItem(KEY, String(Date.now()));
|
||||||
|
event.preventDefault();
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
|||||||
@@ -7,11 +7,14 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe';
|
import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe';
|
||||||
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import MobileCardList from '../components/MobileCardList';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const FREIGABE_FIELD_ID = 15;
|
const FREIGABE_FIELD_ID = 15;
|
||||||
|
|
||||||
export default function FreigabePage() {
|
export default function FreigabePage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [data, setData] = useState<FreigabeDocument[]>([]);
|
const [data, setData] = useState<FreigabeDocument[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -119,7 +122,7 @@ export default function FreigabePage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Erstellt',
|
title: 'Erstellt',
|
||||||
dataIndex: 'created_date',
|
dataIndex: 'created',
|
||||||
key: 'created',
|
key: 'created',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
||||||
@@ -153,10 +156,37 @@ export default function FreigabePage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title>
|
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title>
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<Select
|
||||||
|
style={{ width: '100%', marginBottom: 16 }}
|
||||||
|
value={nurNichtFreigegeben}
|
||||||
|
onChange={(v) => {
|
||||||
|
setPage(1);
|
||||||
|
setNurNichtFreigegeben(v);
|
||||||
|
}}
|
||||||
|
options={[
|
||||||
|
{ value: true, label: 'Nicht freigegeben' },
|
||||||
|
{ value: false, label: 'Alle' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Space style={{ marginBottom: 16 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={nurNichtFreigegeben}
|
value={nurNichtFreigegeben}
|
||||||
@@ -171,26 +201,47 @@ export default function FreigabePage() {
|
|||||||
<Radio.Button value={false}>Alle</Radio.Button>
|
<Radio.Button value={false}>Alle</Radio.Button>
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Space>
|
</Space>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileCardList<FreigabeDocument>
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={paginationConfig}
|
||||||
|
emptyText="Keine Belege vorhanden"
|
||||||
|
renderCard={(doc) => (
|
||||||
|
<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>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'}
|
||||||
|
</Text>
|
||||||
|
{getFreigabeValue(doc)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon={<CheckCircleOutlined />}
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
onClick={() => openModal(doc)}
|
||||||
|
>
|
||||||
|
Freigabe setzen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table<FreigabeDocument>
|
<Table<FreigabeDocument>
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="small"
|
size="small"
|
||||||
pagination={{
|
pagination={paginationConfig}
|
||||||
current: page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
showSizeChanger: true,
|
|
||||||
pageSizeOptions: ['25', '50', '100'],
|
|
||||||
onChange: (p, ps) => {
|
|
||||||
setPage(p);
|
|
||||||
setPageSize(ps);
|
|
||||||
},
|
|
||||||
showTotal: (t) => `${t} Belege`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Freigabe setzen"
|
title="Freigabe setzen"
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
|
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
|
||||||
import { paperlessApi } from '../api/paperless';
|
import { paperlessApi } from '../api/paperless';
|
||||||
import { userSettingsApi, type SenderOption } from '../api/userSettings';
|
import { userSettingsApi, type SenderOption } from '../api/userSettings';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
|
||||||
const ZOOM_MIN = 0.5;
|
const ZOOM_MIN = 0.5;
|
||||||
const ZOOM_MAX = 3;
|
const ZOOM_MAX = 3;
|
||||||
@@ -113,6 +114,7 @@ function CompareModal({
|
|||||||
onCreateNewVersion,
|
onCreateNewVersion,
|
||||||
onSkip,
|
onSkip,
|
||||||
}: CompareModalProps) {
|
}: CompareModalProps) {
|
||||||
|
const compareIsMobile = useIsMobile();
|
||||||
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
|
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
|
||||||
const [inboxUrl, setInboxUrl] = useState<string | null>(null);
|
const [inboxUrl, setInboxUrl] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -165,8 +167,8 @@ function CompareModal({
|
|||||||
</Button>,
|
</Button>,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: 12, height: '75vh' }}>
|
<div style={{ display: 'flex', flexDirection: compareIsMobile ? 'column' : 'row', gap: 12, height: '75vh' }}>
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
|
||||||
<Typography.Text strong style={{ marginBottom: 4 }}>
|
<Typography.Text strong style={{ marginBottom: 4 }}>
|
||||||
Original (Paperless)
|
Original (Paperless)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -180,7 +182,7 @@ function CompareModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
|
||||||
<Typography.Text strong style={{ marginBottom: 4 }}>
|
<Typography.Text strong style={{ marginBottom: 4 }}>
|
||||||
Aktueller Abschnitt (Inbox)
|
Aktueller Abschnitt (Inbox)
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -858,6 +860,7 @@ function SendEmailDialog({ open, fileId, fileName, documents, thumbUrls, onClose
|
|||||||
export default function InboxDetailPage() {
|
export default function InboxDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [file, setFile] = useState<InboxFile | null>(null);
|
const [file, setFile] = useState<InboxFile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [thumbUrls, setThumbUrls] = useState<Map<number, string>>(new Map());
|
const [thumbUrls, setThumbUrls] = useState<Map<number, string>>(new Map());
|
||||||
@@ -1211,13 +1214,13 @@ export default function InboxDetailPage() {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: isMobile ? 'calc(100dvh - 180px)' : 'calc(100vh - 120px)' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12, gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', rowGap: 8, marginBottom: 12, gap: 12 }}>
|
||||||
<Space>
|
<Space wrap>
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
|
||||||
Zurück
|
Zurück
|
||||||
</Button>
|
</Button>
|
||||||
<Title level={3} style={{ margin: 0 }}>
|
<Title level={isMobile ? 5 : 3} style={{ margin: 0, maxWidth: isMobile ? 180 : undefined }} ellipsis={{ tooltip: file.name }}>
|
||||||
{file.name}
|
{file.name}
|
||||||
</Title>
|
</Title>
|
||||||
<SourceTag source={file.source} />
|
<SourceTag source={file.source} />
|
||||||
@@ -1399,12 +1402,13 @@ export default function InboxDetailPage() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 140,
|
width: isMobile ? 84 : 140,
|
||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
padding: 6,
|
padding: 6,
|
||||||
background: '#fafafa',
|
background: '#fafafa',
|
||||||
border: '1px solid #f0f0f0',
|
border: '1px solid #f0f0f0',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{sidebarPages.map((n, idx) => {
|
{sidebarPages.map((n, idx) => {
|
||||||
@@ -1428,7 +1432,7 @@ export default function InboxDetailPage() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: 170,
|
height: isMobile ? 100 : 170,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -1439,7 +1443,7 @@ export default function InboxDetailPage() {
|
|||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={`Seite ${docPage}`}
|
alt={`Seite ${docPage}`}
|
||||||
style={thumbImageStyle(rotationFor(n), 130)}
|
style={thumbImageStyle(rotationFor(n), isMobile ? 72 : 130)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox';
|
|||||||
import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates';
|
import { barcodeTemplatesApi, type BarcodeTemplate } from '../api/barcode-templates';
|
||||||
import { labelPrintAgentApi } from '../api/labelPrintAgent';
|
import { labelPrintAgentApi } from '../api/labelPrintAgent';
|
||||||
import { userSettingsApi } from '../api/userSettings';
|
import { userSettingsApi } from '../api/userSettings';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import MobileCardList from '../components/MobileCardList';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title } = Typography;
|
||||||
|
|
||||||
@@ -51,6 +53,18 @@ function formatDate(iso: string): string {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderSourceTag(src: InboxFile['source']): ReactNode {
|
||||||
|
return src === 'user' ? (
|
||||||
|
<Tag icon={<UserOutlined />} color="purple">
|
||||||
|
Persönlich
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Tag icon={<FolderOpenOutlined />} color="blue">
|
||||||
|
Gemeinsam
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderBarcodes(barcodes: InboxBarcode[]): ReactNode {
|
function renderBarcodes(barcodes: InboxBarcode[]): ReactNode {
|
||||||
if (!barcodes || barcodes.length === 0) {
|
if (!barcodes || barcodes.length === 0) {
|
||||||
return <Typography.Text type="secondary">—</Typography.Text>;
|
return <Typography.Text type="secondary">—</Typography.Text>;
|
||||||
@@ -137,6 +151,7 @@ function buildInitialFieldValues(template: BarcodeTemplate | null): Record<strin
|
|||||||
|
|
||||||
export default function InboxPage() {
|
export default function InboxPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [files, setFiles] = useState<InboxFile[]>([]);
|
const [files, setFiles] = useState<InboxFile[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
@@ -261,68 +276,11 @@ export default function InboxPage() {
|
|||||||
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
|
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
|
||||||
);
|
);
|
||||||
|
|
||||||
const columns: ColumnsType<InboxFile> = [
|
// Karten-Ansicht sortiert wie die Tabelle (neueste zuerst)
|
||||||
{
|
const sortedForMobile = [...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||||
title: 'Dateiname',
|
|
||||||
dataIndex: 'name',
|
const renderActions = (record: InboxFile, direction: 'column' | 'row' = 'column') => (
|
||||||
key: 'name',
|
<div style={{ display: 'flex', flexDirection: direction, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||||
sorter: (a, b) => a.name.localeCompare(b.name),
|
|
||||||
render: (name: string, record) => (
|
|
||||||
<DocumentPreviewPopover record={record}>
|
|
||||||
<Typography.Text>{name}</Typography.Text>
|
|
||||||
</DocumentPreviewPopover>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Quelle',
|
|
||||||
dataIndex: 'source',
|
|
||||||
key: 'source',
|
|
||||||
width: 160,
|
|
||||||
filters: [
|
|
||||||
{ text: 'Gemeinsam', value: 'all' },
|
|
||||||
{ text: 'Persönlich', value: 'user' },
|
|
||||||
],
|
|
||||||
onFilter: (value, record) => record.source === value,
|
|
||||||
render: (src: InboxFile['source']) =>
|
|
||||||
src === 'user' ? (
|
|
||||||
<Tag icon={<UserOutlined />} color="purple">
|
|
||||||
Persönlich
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag icon={<FolderOpenOutlined />} color="blue">
|
|
||||||
Gemeinsam
|
|
||||||
</Tag>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'QR-Code / Vorlage',
|
|
||||||
key: 'barcodes',
|
|
||||||
width: 260,
|
|
||||||
render: (_, record) => renderBarcodes(record.barcodes),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Seiten',
|
|
||||||
dataIndex: 'pageCount',
|
|
||||||
key: 'pageCount',
|
|
||||||
width: 100,
|
|
||||||
sorter: (a, b) => a.pageCount - b.pageCount,
|
|
||||||
render: (n: number) => (n > 0 ? n : <Typography.Text type="secondary">—</Typography.Text>),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Empfangen',
|
|
||||||
dataIndex: 'createdAt',
|
|
||||||
key: 'createdAt',
|
|
||||||
width: 180,
|
|
||||||
defaultSortOrder: 'descend',
|
|
||||||
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
|
|
||||||
render: (iso: string) => formatDate(iso),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Aktionen',
|
|
||||||
key: 'actions',
|
|
||||||
width: 140,
|
|
||||||
render: (_, record) => (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
|
||||||
<Tooltip title="Vorschau öffnen">
|
<Tooltip title="Vorschau öffnen">
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
@@ -360,8 +318,61 @@ export default function InboxPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnsType<InboxFile> = [
|
||||||
|
{
|
||||||
|
title: 'Dateiname',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
sorter: (a, b) => a.name.localeCompare(b.name),
|
||||||
|
render: (name: string, record) => (
|
||||||
|
<DocumentPreviewPopover record={record}>
|
||||||
|
<Typography.Text>{name}</Typography.Text>
|
||||||
|
</DocumentPreviewPopover>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Quelle',
|
||||||
|
dataIndex: 'source',
|
||||||
|
key: 'source',
|
||||||
|
width: 160,
|
||||||
|
filters: [
|
||||||
|
{ text: 'Gemeinsam', value: 'all' },
|
||||||
|
{ text: 'Persönlich', value: 'user' },
|
||||||
|
],
|
||||||
|
onFilter: (value, record) => record.source === value,
|
||||||
|
render: (src: InboxFile['source']) => renderSourceTag(src),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'QR-Code / Vorlage',
|
||||||
|
key: 'barcodes',
|
||||||
|
width: 260,
|
||||||
|
render: (_, record) => renderBarcodes(record.barcodes),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Seiten',
|
||||||
|
dataIndex: 'pageCount',
|
||||||
|
key: 'pageCount',
|
||||||
|
width: 100,
|
||||||
|
sorter: (a, b) => a.pageCount - b.pageCount,
|
||||||
|
render: (n: number) => (n > 0 ? n : <Typography.Text type="secondary">—</Typography.Text>),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Empfangen',
|
||||||
|
dataIndex: 'createdAt',
|
||||||
|
key: 'createdAt',
|
||||||
|
width: 180,
|
||||||
|
defaultSortOrder: 'descend',
|
||||||
|
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
|
||||||
|
render: (iso: string) => formatDate(iso),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Aktionen',
|
||||||
|
key: 'actions',
|
||||||
|
width: 140,
|
||||||
|
render: (_, record) => renderActions(record),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -371,6 +382,8 @@ export default function InboxPage() {
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 12,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -383,11 +396,11 @@ export default function InboxPage() {
|
|||||||
Scan-Ordner.
|
Scan-Ordner.
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space wrap>
|
||||||
<Input
|
<Input
|
||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
placeholder="Suchen …"
|
placeholder="Suchen …"
|
||||||
style={{ width: 260 }}
|
style={{ width: isMobile ? '100%' : 260 }}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
allowClear
|
allowClear
|
||||||
@@ -478,6 +491,33 @@ export default function InboxPage() {
|
|||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileCardList<InboxFile>
|
||||||
|
dataSource={sortedForMobile}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 25,
|
||||||
|
showTotal: (t) => `${t} Dateien`,
|
||||||
|
}}
|
||||||
|
emptyText="Keine Dateien vorhanden"
|
||||||
|
renderCard={(record) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<DocumentPreviewPopover record={record}>
|
||||||
|
<Typography.Text strong>{record.name}</Typography.Text>
|
||||||
|
</DocumentPreviewPopover>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
{renderSourceTag(record.source)}
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
{record.pageCount > 0 ? `${record.pageCount} Seiten` : '—'} · {formatDate(record.createdAt)}
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
{renderBarcodes(record.barcodes)}
|
||||||
|
{renderActions(record, 'row')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<Table<InboxFile>
|
<Table<InboxFile>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
@@ -492,6 +532,7 @@ export default function InboxPage() {
|
|||||||
locale={{ emptyText: 'Keine Dateien vorhanden' }}
|
locale={{ emptyText: 'Keine Dateien vorhanden' }}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,14 @@ import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails';
|
|||||||
import { emailImportApi } from '../api/email-import';
|
import { emailImportApi } from '../api/email-import';
|
||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
import MailImportWizard from '../components/MailImportWizard';
|
import MailImportWizard from '../components/MailImportWizard';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
export default function MailDetailPage() {
|
export default function MailDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [email, setEmail] = useState<EmailItem | null>(null);
|
const [email, setEmail] = useState<EmailItem | null>(null);
|
||||||
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
||||||
const [selected, setSelected] = useState<EmailAttachment | null>(null);
|
const [selected, setSelected] = useState<EmailAttachment | null>(null);
|
||||||
@@ -136,16 +138,16 @@ export default function MailDetailPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
|
||||||
<Space>
|
<Space wrap>
|
||||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
|
||||||
Zurück
|
Zurück
|
||||||
</Button>
|
</Button>
|
||||||
<Title level={3} style={{ margin: 0 }}>{email.Subject}</Title>
|
<Title level={isMobile ? 5 : 3} style={{ margin: 0 }}>{email.Subject}</Title>
|
||||||
{hasErechnung && <Tag color="green">eRechnung</Tag>}
|
{hasErechnung && <Tag color="green">eRechnung</Tag>}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Space>
|
<Space wrap>
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="E-Mail ignorieren"
|
title="E-Mail ignorieren"
|
||||||
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
|
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
|
||||||
@@ -183,12 +185,26 @@ export default function MailDetailPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 3fr', gap: 16, height: 'calc(100vh - 140px)' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : '2fr 3fr',
|
||||||
|
gap: 16,
|
||||||
|
height: isMobile ? 'auto' : 'calc(100vh - 140px)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{/* Linke Seite: E-Mail-Inhalt */}
|
{/* Linke Seite: E-Mail-Inhalt */}
|
||||||
<Card
|
<Card
|
||||||
title="E-Mail"
|
title="E-Mail"
|
||||||
size="small"
|
size="small"
|
||||||
styles={{ body: { overflow: 'auto', height: 'calc(100vh - 200px)', display: 'flex', flexDirection: 'column' } }}
|
styles={{
|
||||||
|
body: {
|
||||||
|
overflow: 'auto',
|
||||||
|
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
},
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<div><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div>
|
<div><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div>
|
||||||
@@ -208,7 +224,17 @@ export default function MailDetailPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Rechte Seite: Anhänge + Vorschau */}
|
{/* Rechte Seite: Anhänge + Vorschau */}
|
||||||
<Card size="small" styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 200px)' } }}>
|
<Card
|
||||||
|
size="small"
|
||||||
|
styles={{
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
height: isMobile ? 'auto' : 'calc(100vh - 200px)',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div style={{ flex: '0 0 auto', borderBottom: `1px solid ${token.colorBorder}`, maxHeight: 240, overflow: 'auto' }}>
|
<div style={{ flex: '0 0 auto', borderBottom: `1px solid ${token.colorBorder}`, maxHeight: 240, overflow: 'auto' }}>
|
||||||
<Table<EmailAttachment>
|
<Table<EmailAttachment>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
@@ -224,7 +250,7 @@ export default function MailDetailPage() {
|
|||||||
locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ flex: 1, minHeight: 0, background: token.colorBgLayout }}>
|
<div style={isMobile ? { height: '60vh', background: token.colorBgLayout } : { flex: 1, minHeight: 0, background: token.colorBgLayout }}>
|
||||||
{previewLoading ? (
|
{previewLoading ? (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||||
<Spin />
|
<Spin />
|
||||||
|
|||||||
@@ -7,11 +7,22 @@ import dayjs from 'dayjs';
|
|||||||
import { emailsApi, type EmailItem } from '../api/emails';
|
import { emailsApi, type EmailItem } from '../api/emails';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { Permission } from '../auth/permissions';
|
import { Permission } from '../auth/permissions';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import MobileCardList from '../components/MobileCardList';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
function renderStatusTag(s: number) {
|
||||||
|
if (s === 0) return <Tag color="blue">Neu</Tag>;
|
||||||
|
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
|
||||||
|
if (s === 2) return <Tag color="red">Fehler</Tag>;
|
||||||
|
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
|
||||||
|
return <Tag>{s}</Tag>;
|
||||||
|
}
|
||||||
|
|
||||||
export default function MailpostfachPage() {
|
export default function MailpostfachPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [emails, setEmails] = useState<EmailItem[]>([]);
|
const [emails, setEmails] = useState<EmailItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [fetching, setFetching] = useState(false);
|
const [fetching, setFetching] = useState(false);
|
||||||
@@ -84,13 +95,7 @@ export default function MailpostfachPage() {
|
|||||||
dataIndex: 'Status',
|
dataIndex: 'Status',
|
||||||
key: 'Status',
|
key: 'Status',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (s: number) => {
|
render: renderStatusTag,
|
||||||
if (s === 0) return <Tag color="blue">Neu</Tag>;
|
|
||||||
if (s === 1) return <Tag color="green">Verarbeitet</Tag>;
|
|
||||||
if (s === 2) return <Tag color="red">Fehler</Tag>;
|
|
||||||
if (s === 3) return <Tag color="default">Ignoriert</Tag>;
|
|
||||||
return <Tag>{s}</Tag>;
|
|
||||||
},
|
|
||||||
filters: [
|
filters: [
|
||||||
{ text: 'Neu', value: 0 },
|
{ text: 'Neu', value: 0 },
|
||||||
{ text: 'Verarbeitet', value: 1 },
|
{ text: 'Verarbeitet', value: 1 },
|
||||||
@@ -116,9 +121,9 @@ export default function MailpostfachPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12, marginBottom: 16 }}>
|
||||||
<Title level={3} style={{ margin: 0 }}>Mailpostfach</Title>
|
<Title level={3} style={{ margin: 0 }}>Mailpostfach</Title>
|
||||||
<Space>
|
<Space wrap>
|
||||||
<Button
|
<Button
|
||||||
icon={<DownloadOutlined />}
|
icon={<DownloadOutlined />}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -198,13 +203,13 @@ export default function MailpostfachPage() {
|
|||||||
prefix={<SearchOutlined />}
|
prefix={<SearchOutlined />}
|
||||||
value={searchText}
|
value={searchText}
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
style={{ width: 300 }}
|
style={{ width: isMobile ? '100%' : 300 }}
|
||||||
allowClear
|
allowClear
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={setStatusFilter}
|
onChange={setStatusFilter}
|
||||||
style={{ width: 200 }}
|
style={{ width: isMobile ? '100%' : 200 }}
|
||||||
options={[
|
options={[
|
||||||
{ value: 'all', label: 'Alle Status' },
|
{ value: 'all', label: 'Alle Status' },
|
||||||
{ value: 0, label: 'Neu' },
|
{ value: 0, label: 'Neu' },
|
||||||
@@ -214,6 +219,35 @@ export default function MailpostfachPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileCardList<EmailItem>
|
||||||
|
dataSource={[...filteredEmails].sort(
|
||||||
|
(a, b) => new Date(b.Date).getTime() - new Date(a.Date).getTime(),
|
||||||
|
)}
|
||||||
|
rowKey="Id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 20, showTotal: (t) => `${t} E-Mails` }}
|
||||||
|
onCardClick={(record) => navigate(`/mailpostfach/${record.Id}`)}
|
||||||
|
renderCard={(record) => {
|
||||||
|
const hasErechnung = record.Attachments?.some((a) => a.Erechnung);
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }}>
|
||||||
|
<Text strong>{record.Subject || '—'}</Text>
|
||||||
|
{hasErechnung && <Tag color="green">eRechnung</Tag>}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>{record.SenderAddress}</Text>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
{record.Date ? dayjs(record.Date).format('DD.MM.YYYY HH:mm') : '-'}
|
||||||
|
</Text>
|
||||||
|
{renderStatusTag(record.Status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table<EmailItem>
|
<Table<EmailItem>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={filteredEmails}
|
dataSource={filteredEmails}
|
||||||
@@ -226,6 +260,7 @@ export default function MailpostfachPage() {
|
|||||||
style: { cursor: 'pointer' },
|
style: { cursor: 'pointer' },
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Table, Popover, Button, Space, message, Tooltip, Typography, Tag } from 'antd';
|
import { Table, Popover, Button, Space, message, Tooltip, Typography, Tag } from 'antd';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { posteingangApi } from '../api/posteingang';
|
import { posteingangApi } from '../api/posteingang';
|
||||||
@@ -11,8 +11,11 @@ import type { PaperlessTag } from '../api/paperless';
|
|||||||
import DocumentEditModal from '../components/DocumentEditModal';
|
import DocumentEditModal from '../components/DocumentEditModal';
|
||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
import { AuthImage } from '../utils/auth-resource';
|
import { AuthImage } from '../utils/auth-resource';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import MobileCardList from '../components/MobileCardList';
|
||||||
|
|
||||||
export default function ManuellBearbeitenPage() {
|
export default function ManuellBearbeitenPage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [data, setData] = useState<PosteingangDocument[]>([]);
|
const [data, setData] = useState<PosteingangDocument[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -77,6 +80,26 @@ export default function ManuellBearbeitenPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getContentTags = (record: PosteingangDocument) =>
|
||||||
|
(record.tags || [])
|
||||||
|
.filter(id => !steuertagIds.includes(id))
|
||||||
|
.map(id => allTags.find(t => t.id === id))
|
||||||
|
.filter((t): t is PaperlessTag => !!t);
|
||||||
|
|
||||||
|
const renderContentTags = (record: PosteingangDocument) => {
|
||||||
|
const contentTags = getContentTags(record);
|
||||||
|
if (contentTags.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
|
||||||
|
{contentTags.map(t => (
|
||||||
|
<Tag key={t.id} color={t.color} style={{ color: t.text_color, margin: 0 }}>
|
||||||
|
{t.name}
|
||||||
|
</Tag>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: 'Vorschau',
|
title: 'Vorschau',
|
||||||
@@ -97,26 +120,12 @@ export default function ManuellBearbeitenPage() {
|
|||||||
dataIndex: 'title',
|
dataIndex: 'title',
|
||||||
key: 'title',
|
key: 'title',
|
||||||
width: '35%',
|
width: '35%',
|
||||||
render: (_: any, record: PosteingangDocument) => {
|
render: (_: any, record: PosteingangDocument) => (
|
||||||
const contentTags = (record.tags || [])
|
|
||||||
.filter(id => !steuertagIds.includes(id))
|
|
||||||
.map(id => allTags.find(t => t.id === id))
|
|
||||||
.filter((t): t is PaperlessTag => !!t);
|
|
||||||
return (
|
|
||||||
<div>
|
<div>
|
||||||
<div>{record.title}</div>
|
<div>{record.title}</div>
|
||||||
{contentTags.length > 0 && (
|
{renderContentTags(record)}
|
||||||
<Space size={[4, 4]} wrap style={{ marginTop: 4 }}>
|
|
||||||
{contentTags.map(t => (
|
|
||||||
<Tag key={t.id} color={t.color} style={{ color: t.text_color, margin: 0 }}>
|
|
||||||
{t.name}
|
|
||||||
</Tag>
|
|
||||||
))}
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
),
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Eingangsdatum',
|
title: 'Eingangsdatum',
|
||||||
@@ -153,6 +162,38 @@ export default function ManuellBearbeitenPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileCardList<PosteingangDocument>
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }}
|
||||||
|
renderCard={(record) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<AuthImage
|
||||||
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||||
|
width={72}
|
||||||
|
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||||
|
<Text strong>{record.title || '—'}</Text>
|
||||||
|
{renderContentTags(record)}
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Eingangsdatum: {record.created ? dayjs(record.created).format('DD.MM.YYYY') : '-'}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" block onClick={() => handleEdit(record)}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
@@ -161,6 +202,7 @@ export default function ManuellBearbeitenPage() {
|
|||||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
||||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DocumentEditModal
|
<DocumentEditModal
|
||||||
documentId={selectedDoc?.id || null}
|
documentId={selectedDoc?.id || null}
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import { Table, Popover, Button, Space, message, Tooltip, Typography } from 'ant
|
|||||||
import { AuthImage } from '../utils/auth-resource';
|
import { AuthImage } from '../utils/auth-resource';
|
||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { posteingangApi } from '../api/posteingang';
|
import { posteingangApi } from '../api/posteingang';
|
||||||
import type { PosteingangDocument } from '../api/posteingang';
|
import type { PosteingangDocument } from '../api/posteingang';
|
||||||
import DocumentEditModal from '../components/DocumentEditModal';
|
import DocumentEditModal from '../components/DocumentEditModal';
|
||||||
import { getEnv } from '../utils/env';
|
import { getEnv } from '../utils/env';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import MobileCardList from '../components/MobileCardList';
|
||||||
|
|
||||||
export default function PosteingangPage() {
|
export default function PosteingangPage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [data, setData] = useState<PosteingangDocument[]>([]);
|
const [data, setData] = useState<PosteingangDocument[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||||
@@ -64,6 +67,11 @@ export default function PosteingangPage() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getEingangsdatum = (record: PosteingangDocument) => {
|
||||||
|
const cf = record.customFields?.find((f) => f.field === 9);
|
||||||
|
return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-';
|
||||||
|
};
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: 'Vorschau',
|
title: 'Vorschau',
|
||||||
@@ -88,10 +96,7 @@ export default function PosteingangPage() {
|
|||||||
{
|
{
|
||||||
title: 'Eingangsdatum',
|
title: 'Eingangsdatum',
|
||||||
key: 'eingangsdatum',
|
key: 'eingangsdatum',
|
||||||
render: (_: any, record: PosteingangDocument) => {
|
render: (_: any, record: PosteingangDocument) => getEingangsdatum(record),
|
||||||
const cf = record.customFields?.find((f) => f.field === 9);
|
|
||||||
return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-';
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Importiert am',
|
title: 'Importiert am',
|
||||||
@@ -121,6 +126,37 @@ export default function PosteingangPage() {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileCardList<PosteingangDocument>
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 20, showTotal: (t) => `${t} Dokumente` }}
|
||||||
|
renderCard={(record) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<AuthImage
|
||||||
|
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
|
||||||
|
width={72}
|
||||||
|
style={{ border: '1px solid #d9d9d9', objectFit: 'contain', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, minWidth: 0 }}>
|
||||||
|
<Text strong>{record.title || '—'}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Eingangsdatum: {getEingangsdatum(record)}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Importiert am: {dayjs(record.added).format('DD.MM.YYYY HH:mm')}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button type="primary" block onClick={() => handleEdit(record)}>
|
||||||
|
Bearbeiten
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
@@ -129,6 +165,7 @@ export default function PosteingangPage() {
|
|||||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
||||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<DocumentEditModal
|
<DocumentEditModal
|
||||||
documentId={selectedDoc?.id || null}
|
documentId={selectedDoc?.id || null}
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ function UserClientsTab() {
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}>
|
||||||
Zuordnung hinzufügen
|
Zuordnung hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
|
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
|
||||||
|
|
||||||
<Divider />
|
<Divider />
|
||||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe — Agrarmonitor-Zuordnung</Typography.Title>
|
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe — Agrarmonitor-Zuordnung</Typography.Title>
|
||||||
@@ -335,6 +335,7 @@ function UserClientsTab() {
|
|||||||
rowKey="Id"
|
rowKey="Id"
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
|
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
|
||||||
@@ -606,6 +607,7 @@ function DocTypesTab() {
|
|||||||
rowKey="Id"
|
rowKey="Id"
|
||||||
size="small"
|
size="small"
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
<Modal
|
<Modal
|
||||||
title="Dokumenttyp bearbeiten"
|
title="Dokumenttyp bearbeiten"
|
||||||
@@ -1039,7 +1041,7 @@ function PostprocessingTab() {
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
|
||||||
Regel hinzufügen
|
Regel hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
|
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={isNew ? 'Neue Postprocessing-Regel' : 'Regel bearbeiten'}
|
title={isNew ? 'Neue Postprocessing-Regel' : 'Regel bearbeiten'}
|
||||||
@@ -1181,7 +1183,7 @@ function ExportTargetsTab() {
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
|
||||||
Export-Ziel hinzufügen
|
Export-Ziel hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} />
|
<Table dataSource={data} columns={columns} loading={loading} rowKey="Id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
|
||||||
|
|
||||||
<Modal title={isNew ? 'Neues Export-Ziel' : 'Export-Ziel bearbeiten'} open={!!editing} onOk={handleSave} onCancel={() => setEditing(null)}>
|
<Modal title={isNew ? 'Neues Export-Ziel' : 'Export-Ziel bearbeiten'} open={!!editing} onOk={handleSave} onCancel={() => setEditing(null)}>
|
||||||
<Form form={form} layout="vertical">
|
<Form form={form} layout="vertical">
|
||||||
@@ -1247,6 +1249,7 @@ function PostprocessingLogsTab() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
rowKey="Id"
|
rowKey="Id"
|
||||||
size="small"
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
total,
|
total,
|
||||||
@@ -1352,7 +1355,7 @@ function ApiKeysTab() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table dataSource={data} columns={columns} loading={loading} rowKey="id" size="small" pagination={false} />
|
<Table dataSource={data} columns={columns} loading={loading} rowKey="id" size="small" pagination={false} scroll={{ x: 'max-content' }} />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Neuen API-Key erstellen"
|
title="Neuen API-Key erstellen"
|
||||||
@@ -1578,6 +1581,7 @@ function CorrespondentsTab() {
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
size="small"
|
size="small"
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
pagination={{
|
pagination={{
|
||||||
current: currentPage,
|
current: currentPage,
|
||||||
pageSize: pageSize,
|
pageSize: pageSize,
|
||||||
@@ -1911,7 +1915,7 @@ function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) {
|
|||||||
<h4 style={{ margin: 0 }}>Weiterverarbeitungs-Aktionen</h4>
|
<h4 style={{ margin: 0 }}>Weiterverarbeitungs-Aktionen</h4>
|
||||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button>
|
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Table<InboxAction> rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" />
|
<Table<InboxAction> rowKey="Id" columns={columns} dataSource={actions} loading={loading} pagination={false} size="small" scroll={{ x: 'max-content' }} />
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'}
|
title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'}
|
||||||
@@ -2168,6 +2172,7 @@ function BarcodeTemplatesTab() {
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -2600,6 +2605,20 @@ function AgrarmonitorTab() {
|
|||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="importWartezeitMinuten"
|
||||||
|
label="Wartezeit bis „zurück“ (Minuten)"
|
||||||
|
tooltip="Wie lange nach dem Versand gewartet wird, bevor ein Beleg als „Von Agrarmonitor zurück“ markiert wird (Import kann bis zu 10 Min dauern)."
|
||||||
|
>
|
||||||
|
<Input placeholder="10" style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="notizMarker"
|
||||||
|
label="Notiz-Marker (Text der AM-Sende-Notiz)"
|
||||||
|
tooltip="Text, an dem die Sende-Notiz erkannt wird (Teil-Übereinstimmung, Groß-/Kleinschreibung egal). Deren Zeitstempel bestimmt die Wartezeit."
|
||||||
|
>
|
||||||
|
<Input placeholder="Agrarmonitor" style={{ width: 280 }} />
|
||||||
|
</Form.Item>
|
||||||
<Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}>
|
<Button type="primary" loading={pollingSaving} onClick={handleSavePollingConfig}>
|
||||||
Speichern
|
Speichern
|
||||||
</Button>
|
</Button>
|
||||||
@@ -2774,8 +2793,8 @@ function WebhookStatusTab() {
|
|||||||
|
|
||||||
const renderStatusBadge = (s: string) => {
|
const renderStatusBadge = (s: string) => {
|
||||||
switch (s) {
|
switch (s) {
|
||||||
|
case 'queued': return <Badge status="processing" text="In Warteschlange" />;
|
||||||
case 'processed': return <Badge status="success" text="Verarbeitet" />;
|
case 'processed': return <Badge status="success" text="Verarbeitet" />;
|
||||||
case 'skipped': return <Badge status="warning" text="Übersprungen" />;
|
|
||||||
case 'error': return <Badge status="error" text="Fehler" />;
|
case 'error': return <Badge status="error" text="Fehler" />;
|
||||||
case 'bad-request': return <Badge status="error" text="Ungültige Anfrage" />;
|
case 'bad-request': return <Badge status="error" text="Ungültige Anfrage" />;
|
||||||
default: return <Badge status="default" text={s} />;
|
default: return <Badge status="default" text={s} />;
|
||||||
@@ -2783,6 +2802,7 @@ function WebhookStatusTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const lastCall = status?.lastCall ?? null;
|
const lastCall = status?.lastCall ?? null;
|
||||||
|
const queueSize = status?.queueSize ?? 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -2791,13 +2811,19 @@ function WebhookStatusTab() {
|
|||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
Paperless-NGX ruft nach dem Bearbeiten eines Dokuments den Webhook{' '}
|
Paperless-NGX ruft nach dem Bearbeiten eines Dokuments den Webhook{' '}
|
||||||
<Typography.Text code>/api/webhook/paperless</Typography.Text> auf
|
<Typography.Text code>/api/webhook/paperless</Typography.Text> auf
|
||||||
(per API-Key authentifiziert). Hier siehst du den zuletzt
|
(per API-Key authentifiziert). Die ID wird in eine Warteschlange
|
||||||
verarbeiteten Aufruf. Eine vollständige Historie steht in den
|
gelegt und von einem separaten Prozess nacheinander verarbeitet.
|
||||||
Backend-Logs.
|
Hier siehst du den zuletzt festgehaltenen Vorgang. Eine vollständige
|
||||||
|
Historie steht in den Backend-Logs.
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
<Space>
|
||||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={load}>
|
<Button icon={<ReloadOutlined />} loading={loading} onClick={load}>
|
||||||
Aktualisieren
|
Aktualisieren
|
||||||
</Button>
|
</Button>
|
||||||
|
<Tag color={queueSize > 0 ? 'processing' : 'default'}>
|
||||||
|
Warteschlange: {queueSize}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card size="small" title="Letzter Webhook-Aufruf" loading={loading}>
|
<Card size="small" title="Letzter Webhook-Aufruf" loading={loading}>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider } from 'antd';
|
import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider, Typography } from 'antd';
|
||||||
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { tasksApi } from '../api/tasks';
|
import { tasksApi } from '../api/tasks';
|
||||||
import type { Task } from '../api/tasks';
|
import type { Task } from '../api/tasks';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import MobileCardList from '../components/MobileCardList';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
function statusTag(fertig: number | null) {
|
function statusTag(fertig: number | null) {
|
||||||
if (fertig === 1) return <Tag color="success">Fertig</Tag>;
|
if (fertig === 1) return <Tag color="success">Fertig</Tag>;
|
||||||
@@ -12,6 +16,7 @@ function statusTag(fertig: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TaskLogPage() {
|
export default function TaskLogPage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [data, setData] = useState<Task[]>([]);
|
const [data, setData] = useState<Task[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [deleting, setDeleting] = useState(false);
|
const [deleting, setDeleting] = useState(false);
|
||||||
@@ -155,6 +160,46 @@ export default function TaskLogPage() {
|
|||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileCardList<Task>
|
||||||
|
dataSource={data}
|
||||||
|
rowKey="TaskId"
|
||||||
|
loading={loading}
|
||||||
|
pagination={{ pageSize: 20 }}
|
||||||
|
renderCard={(record) => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Text strong>{record.InterneBelegnummer || '—'}</Text>
|
||||||
|
{statusTag(record.Fertig)}
|
||||||
|
</div>
|
||||||
|
<Text type="secondary" style={{ fontFamily: 'monospace', fontSize: 12 }}>
|
||||||
|
{record.TaskId.slice(0, 8)}…
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>Lieferant: {record.Lieferant || '-'}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Belegdatum: {record.Belegdatum ? dayjs(record.Belegdatum).format('DD.MM.YYYY') : '-'}
|
||||||
|
{' · '}
|
||||||
|
Eingang: {record.Eingangsdatum ? dayjs(record.Eingangsdatum).format('DD.MM.YYYY') : '-'}
|
||||||
|
</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||||
|
Paperless-Dok.-ID: {record.PaperlessDocumentID ?? '-'}
|
||||||
|
</Text>
|
||||||
|
<Popconfirm
|
||||||
|
title="Task löschen"
|
||||||
|
description={`Task ${record.TaskId.slice(0, 8)}… dauerhaft entfernen?`}
|
||||||
|
onConfirm={() => handleDeleteOne(record.TaskId)}
|
||||||
|
okText="Löschen"
|
||||||
|
cancelText="Abbrechen"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
>
|
||||||
|
<Button danger size="small" icon={<DeleteOutlined />} block>
|
||||||
|
Löschen
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={columns}
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
@@ -163,6 +208,7 @@ export default function TaskLogPage() {
|
|||||||
pagination={{ pageSize: 20 }}
|
pagination={{ pageSize: 20 }}
|
||||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,13 +5,22 @@ import type { ColumnsType } from 'antd/es/table';
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung';
|
import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung';
|
||||||
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile';
|
||||||
|
import MobileCardList from '../components/MobileCardList';
|
||||||
|
|
||||||
const { Title } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const FREIGABE_FIELD_ID = 15;
|
const FREIGABE_FIELD_ID = 15;
|
||||||
const ZAHLUNG_FIELD_ID = 16;
|
const ZAHLUNG_FIELD_ID = 16;
|
||||||
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
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() {
|
export default function ZahlungPage() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [data, setData] = useState<ZahlungDocument[]>([]);
|
const [data, setData] = useState<ZahlungDocument[]>([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -132,7 +141,7 @@ export default function ZahlungPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Erstellt',
|
title: 'Erstellt',
|
||||||
dataIndex: 'created_date',
|
dataIndex: 'created',
|
||||||
key: 'created',
|
key: 'created',
|
||||||
width: 110,
|
width: 110,
|
||||||
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
||||||
@@ -178,10 +187,34 @@ export default function ZahlungPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Zahlung</Title>
|
<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 }}>
|
<Space style={{ marginBottom: 16 }}>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
value={filter}
|
value={filter}
|
||||||
@@ -192,31 +225,62 @@ export default function ZahlungPage() {
|
|||||||
optionType="button"
|
optionType="button"
|
||||||
buttonStyle="solid"
|
buttonStyle="solid"
|
||||||
>
|
>
|
||||||
<Radio.Button value="ausstehend">Freigegeben, noch nicht bezahlt</Radio.Button>
|
{FILTER_OPTIONS.map((o) => (
|
||||||
<Radio.Button value="freigegeben">Alle freigegebenen</Radio.Button>
|
<Radio.Button key={o.value} value={o.value}>{o.label}</Radio.Button>
|
||||||
<Radio.Button value="alle">Alle</Radio.Button>
|
))}
|
||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</Space>
|
</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>
|
<Table<ZahlungDocument>
|
||||||
dataSource={data}
|
dataSource={data}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size="small"
|
size="small"
|
||||||
pagination={{
|
pagination={paginationConfig}
|
||||||
current: page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
showSizeChanger: true,
|
|
||||||
pageSizeOptions: ['25', '50', '100'],
|
|
||||||
onChange: (p, ps) => {
|
|
||||||
setPage(p);
|
|
||||||
setPageSize(ps);
|
|
||||||
},
|
|
||||||
showTotal: (t) => `${t} Belege`,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
title="Zahlung verbuchen"
|
title="Zahlung verbuchen"
|
||||||
|
|||||||
Reference in New Issue
Block a user