Merge pull request 'Freigabe → main: Mobile UI, Webhook, IMAP, Zahlung-Workflow und weitere Features' (#5) from Freigabe into main
Build and Push Multi-Platform Images / build-and-push (push) Successful in 8s
Build and Push Multi-Platform Images / build-and-push (push) Successful in 8s
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
@@ -7,6 +7,13 @@
|
||||
# Produktion: VITE_API_URL leer lassen (nginx Reverse-Proxy leitet /api weiter)
|
||||
# =============================================================================
|
||||
|
||||
# --- Umgebung ---
|
||||
# Produktion: NODE_ENV=production -> KEIN TypeORM-synchronize (Schema via Migrationen),
|
||||
# Migrationen werden beim Start automatisch ausgeführt (migrationsRun).
|
||||
# Aktiviert zudem CORS-Schutz (siehe CORS_ORIGIN weiter unten).
|
||||
# Entwicklung: NODE_ENV leer lassen -> synchronize ON (Schema folgt den Entities).
|
||||
NODE_ENV=production
|
||||
|
||||
# --- Ports ---
|
||||
BACKEND_PORT=7601
|
||||
FRONTEND_PORT=7600
|
||||
|
||||
@@ -14,3 +14,6 @@ dist/
|
||||
|
||||
# Docker
|
||||
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**.
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
@@ -78,11 +84,37 @@ Use `@Public()` to bypass auth guards entirely.
|
||||
|
||||
### Database
|
||||
|
||||
- TypeORM with MySQL 8+, UTF8MB4, `synchronize: true` (schema auto-migrates)
|
||||
- TypeORM with MySQL/MariaDB, UTF8MB4
|
||||
- Connection config lives in `src/database/data-source.ts` (single source of truth,
|
||||
shared by the NestJS runtime and the TypeORM CLI). `database.module.ts` consumes it.
|
||||
- **Schema strategy depends on `NODE_ENV`:**
|
||||
- Dev (`NODE_ENV` unset): `synchronize: true` — schema auto-migrates from entities.
|
||||
- Production (`NODE_ENV=production`): `synchronize: false` + `migrationsRun: true` —
|
||||
pending migrations in `src/database/migrations/` are applied automatically on boot.
|
||||
**Never run `synchronize` against the production DB** — on MariaDB it issues
|
||||
destructive `ADD`/`DROP COLUMN` churn every boot (see caveat below).
|
||||
- 23 entities in `src/database/entities/`
|
||||
- JSON columns use transformers to normalize empty arrays/objects to `null`
|
||||
- Key entities: `InboxDocument`, `Task`, `Email`, `Attachment`, `Postprocessing`, `BarcodeTemplate`, `LabelPrintJob`, `ApiKey`, `Setting`
|
||||
|
||||
#### Migrations workflow
|
||||
|
||||
```bash
|
||||
npm run migration:generate -- src/database/migrations/<Name> # diff entities → DB
|
||||
npm run migration:run # apply pending
|
||||
npm run migration:revert # roll back last
|
||||
```
|
||||
|
||||
The existing production schema is the implicit baseline (no baseline migration); only
|
||||
future changes get migration files.
|
||||
|
||||
**Caveat — MariaDB reports `json` as `longtext`:** every `@Column({ type: 'json' })`
|
||||
is stored as `longtext ... CHECK (json_valid(...))`, so `migration:generate` always
|
||||
emits spurious no-op `CHANGE` statements (json↔longtext, nullable/default re-declares).
|
||||
**Hand-trim generated migrations** down to the real change before committing. (This same
|
||||
false diff is exactly why `synchronize` must stay off in production — left unchecked it
|
||||
accumulates `ALGORITHM=INSTANT` drops until a table trips the 8126-byte row-size limit.)
|
||||
|
||||
### Document Processing Pipeline
|
||||
|
||||
**Preprocessing** (`preprocessing/document-pipeline.service.ts`):
|
||||
|
||||
@@ -8,6 +8,8 @@ services:
|
||||
ports:
|
||||
- "${BACKEND_PORT:-7601}:3100"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- CORS_ORIGIN=${CORS_ORIGIN:-}
|
||||
- PORT=3100
|
||||
- DB_HOST=${DB_HOST:-db}
|
||||
- DB_PORT=${DB_PORT:-3306}
|
||||
@@ -36,6 +38,8 @@ services:
|
||||
- IMAP_USE_SSL=${IMAP_USE_SSL:-true}
|
||||
- IMAP_USERNAME=${IMAP_USERNAME:-}
|
||||
- IMAP_PASSWORD=${IMAP_PASSWORD:-}
|
||||
- IMAP_IMPORTED_FOLDER=${IMAP_IMPORTED_FOLDER:-importiert}
|
||||
- IMAP_TRASH_FOLDER=${IMAP_TRASH_FOLDER:-Trash}
|
||||
- BELEGNUMMER_GET_URL=${BELEGNUMMER_GET_URL:-}
|
||||
- BELEGNUMMER_SET_URL=${BELEGNUMMER_SET_URL:-}
|
||||
- AGRARMONITOR_BASE_URL=${AGRARMONITOR_BASE_URL:-https://admin7.agrarmonitor.de}
|
||||
|
||||
Generated
+16
-3
@@ -24,6 +24,7 @@
|
||||
"axios": "^1.14.0",
|
||||
"basic-ftp": "^5.2.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"form-data": "^4.0.5",
|
||||
"imapflow": "^1.3.2",
|
||||
"jsqr": "^1.4.0",
|
||||
@@ -3023,6 +3024,18 @@
|
||||
"rxjs": "^7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/config/node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://dotenvx.com"
|
||||
}
|
||||
},
|
||||
"node_modules/@nestjs/core": {
|
||||
"version": "11.1.17",
|
||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.17.tgz",
|
||||
@@ -6291,9 +6304,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "17.2.3",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
|
||||
"version": "17.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
|
||||
"integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"typeorm": "typeorm-ts-node-commonjs -d ./src/database/data-source.ts",
|
||||
"migration:generate": "npm run typeorm -- migration:generate",
|
||||
"migration:run": "npm run typeorm -- migration:run",
|
||||
"migration:revert": "npm run typeorm -- migration:revert"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.1",
|
||||
@@ -35,6 +39,7 @@
|
||||
"axios": "^1.14.0",
|
||||
"basic-ftp": "^5.2.1",
|
||||
"chokidar": "^4.0.3",
|
||||
"dotenv": "^17.4.2",
|
||||
"form-data": "^4.0.5",
|
||||
"imapflow": "^1.3.2",
|
||||
"jsqr": "^1.4.0",
|
||||
|
||||
@@ -56,6 +56,8 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
||||
await this.upsertSetting('agrarmonitor_tag_hochgeladen', '');
|
||||
await this.upsertSetting('agrarmonitor_link_field', '');
|
||||
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 * * * *')
|
||||
@@ -80,21 +82,36 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
||||
tagHochgeladen: string;
|
||||
linkField: string;
|
||||
tagManuell: string;
|
||||
importWartezeitMinuten: string;
|
||||
notizMarker: string;
|
||||
}> {
|
||||
const [fertig, verbucht, hochgeladen, linkField, manuell] =
|
||||
await Promise.all([
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
||||
]);
|
||||
const [
|
||||
fertig,
|
||||
verbucht,
|
||||
hochgeladen,
|
||||
linkField,
|
||||
manuell,
|
||||
wartezeit,
|
||||
marker,
|
||||
] = await Promise.all([
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_verbucht' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_manuell' }),
|
||||
this.settingRepo.findOneBy({
|
||||
Tag: 'agrarmonitor_import_wartezeit_minuten',
|
||||
}),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_notiz_marker' }),
|
||||
]);
|
||||
return {
|
||||
tagFertig: fertig?.Wert ?? '4',
|
||||
tagVerbucht: verbucht?.Wert ?? '9',
|
||||
tagHochgeladen: hochgeladen?.Wert ?? '',
|
||||
linkField: linkField?.Wert ?? '',
|
||||
tagManuell: manuell?.Wert ?? '',
|
||||
importWartezeitMinuten: wartezeit?.Wert ?? '10',
|
||||
notizMarker: marker?.Wert ?? 'Agrarmonitor',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,12 +121,16 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
||||
tagHochgeladen: string,
|
||||
linkField: string,
|
||||
tagManuell: string,
|
||||
importWartezeitMinuten: string,
|
||||
notizMarker: string,
|
||||
): Promise<{
|
||||
tagFertig: string;
|
||||
tagVerbucht: string;
|
||||
tagHochgeladen: string;
|
||||
linkField: string;
|
||||
tagManuell: string;
|
||||
importWartezeitMinuten: string;
|
||||
notizMarker: string;
|
||||
}> {
|
||||
await Promise.all([
|
||||
this.settingRepo.update(
|
||||
@@ -132,8 +153,24 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
||||
{ Tag: 'agrarmonitor_tag_manuell' },
|
||||
{ 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> {
|
||||
@@ -393,17 +430,26 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
||||
fertigSetting,
|
||||
linkFieldSetting,
|
||||
manuellSetting,
|
||||
wartezeitSetting,
|
||||
markerSetting,
|
||||
] = await Promise.all([
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_hochgeladen' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_tag_fertig' }),
|
||||
this.settingRepo.findOneBy({ Tag: 'agrarmonitor_link_field' }),
|
||||
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 tagFertigId = parseInt(fertigSetting?.Wert ?? '4', 10);
|
||||
const linkFieldId = parseInt(linkFieldSetting?.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)) {
|
||||
this.logger.warn(
|
||||
@@ -512,6 +558,40 @@ export class AgrarmonitorPollingService implements OnModuleInit {
|
||||
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
|
||||
if (!isNaN(tagManuellId)) {
|
||||
const currentTags: number[] = (doc.tags as number[]) ?? [];
|
||||
|
||||
@@ -42,6 +42,8 @@ export class AgrarmonitorController {
|
||||
tagHochgeladen: string;
|
||||
linkField: string;
|
||||
tagManuell: string;
|
||||
importWartezeitMinuten: string;
|
||||
notizMarker: string;
|
||||
},
|
||||
) {
|
||||
return this.pollingService.updatePollingConfig(
|
||||
@@ -50,6 +52,8 @@ export class AgrarmonitorController {
|
||||
body.tagHochgeladen,
|
||||
body.linkField,
|
||||
body.tagManuell ?? '',
|
||||
body.importWartezeitMinuten ?? '10',
|
||||
body.notizMarker ?? 'Agrarmonitor',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { UserSettingsModule } from './user-settings/user-settings.module';
|
||||
import { LabelPrintAgentModule } from './label-print-agent/label-print-agent.module';
|
||||
import { AgrarmonitorModule } from './agrarmonitor/agrarmonitor.module';
|
||||
import { FreigabeModule } from './freigabe/freigabe.module';
|
||||
import { ZahlungModule } from './zahlung/zahlung.module';
|
||||
import { DailyDigestModule } from './daily-digest/daily-digest.module';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -52,6 +53,7 @@ import * as path from 'path';
|
||||
LabelPrintAgentModule,
|
||||
AgrarmonitorModule,
|
||||
FreigabeModule,
|
||||
ZahlungModule,
|
||||
DailyDigestModule,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -28,12 +28,19 @@ export class ApiKeyGuard implements CanActivate {
|
||||
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) {
|
||||
const auth: string | undefined = request.headers['authorization'];
|
||||
if (auth?.startsWith('Bearer ')) {
|
||||
apiKey = auth.slice(7);
|
||||
source = 'Authorization: Bearer';
|
||||
const token = auth.slice(7);
|
||||
if (token.startsWith('pm_')) {
|
||||
apiKey = token;
|
||||
source = 'Authorization: Bearer';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const Permission = {
|
||||
VIEW_SCANNER: 'VIEW_SCANNER',
|
||||
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
||||
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
||||
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
|
||||
} as const;
|
||||
|
||||
export type Permission = (typeof Permission)[keyof typeof Permission];
|
||||
@@ -27,6 +28,7 @@ export function mapGroupsToPermissions(
|
||||
permissions.add(Permission.VIEW_SCANNER);
|
||||
permissions.add(Permission.MANAGE_SETTINGS);
|
||||
permissions.add(Permission.VIEW_FREIGABE);
|
||||
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ export function mapGroupsToPermissions(
|
||||
if (groups.includes('PM_Posteingang')) permissions.add(Permission.VIEW_INBOX);
|
||||
if (groups.includes('PM_Scanner')) permissions.add(Permission.VIEW_SCANNER);
|
||||
if (groups.includes('PM_Freigabe')) permissions.add(Permission.VIEW_FREIGABE);
|
||||
if (groups.includes('PM_Zahlung')) permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'reflect-metadata';
|
||||
import { join } from 'path';
|
||||
import { config as loadEnv } from 'dotenv';
|
||||
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||
import {
|
||||
Client,
|
||||
DocumentType,
|
||||
DocumentField,
|
||||
Task,
|
||||
Postprocessing,
|
||||
PostprocessingAction,
|
||||
PostprocessingLog,
|
||||
ExportTarget,
|
||||
Setting,
|
||||
Kontonummer,
|
||||
Document,
|
||||
UserClient,
|
||||
Email,
|
||||
Attachment,
|
||||
Content,
|
||||
ApiKey,
|
||||
CorrespondentSetting,
|
||||
BarcodeTemplate,
|
||||
InboxDocument,
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
WebhookQueueItem,
|
||||
} from './entities';
|
||||
|
||||
// CLI-Kontext: .env laden (Laufzeit im Container liefert die Variablen via Docker,
|
||||
// dotenv ist dort ein No-Op). dotenv überschreibt bereits gesetzte Variablen nicht.
|
||||
loadEnv();
|
||||
loadEnv({ path: join(process.cwd(), '..', '.env') });
|
||||
|
||||
export const entities = [
|
||||
Client,
|
||||
DocumentType,
|
||||
DocumentField,
|
||||
Task,
|
||||
Postprocessing,
|
||||
PostprocessingAction,
|
||||
PostprocessingLog,
|
||||
ExportTarget,
|
||||
Setting,
|
||||
Kontonummer,
|
||||
Document,
|
||||
UserClient,
|
||||
Email,
|
||||
Attachment,
|
||||
Content,
|
||||
ApiKey,
|
||||
CorrespondentSetting,
|
||||
BarcodeTemplate,
|
||||
InboxDocument,
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
WebhookQueueItem,
|
||||
];
|
||||
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
export const dataSourceOptions: DataSourceOptions = {
|
||||
type: 'mysql',
|
||||
host: process.env.DB_HOST ?? 'localhost',
|
||||
port: Number(process.env.DB_PORT ?? 3306),
|
||||
username: process.env.DB_USERNAME ?? 'root',
|
||||
password: process.env.DB_PASSWORD ?? '',
|
||||
database: process.env.DB_DATABASE ?? 'paperlessadd',
|
||||
charset: 'utf8mb4',
|
||||
entities,
|
||||
migrations: [join(__dirname, 'migrations', '*.{ts,js}')],
|
||||
// In Produktion: kein synchronize (zerstörerischer ALTER-Churn), Migrationen
|
||||
// werden beim Start automatisch ausgeführt. In Dev: synchronize wie bisher.
|
||||
synchronize: !isProduction,
|
||||
migrationsRun: isProduction,
|
||||
};
|
||||
|
||||
// Wird von der TypeORM-CLI verwendet (migration:generate / :run / :revert).
|
||||
export default new DataSource(dataSourceOptions);
|
||||
@@ -1,75 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import {
|
||||
Client,
|
||||
DocumentType,
|
||||
DocumentField,
|
||||
Task,
|
||||
Postprocessing,
|
||||
PostprocessingAction,
|
||||
PostprocessingLog,
|
||||
ExportTarget,
|
||||
Setting,
|
||||
Kontonummer,
|
||||
Document,
|
||||
UserClient,
|
||||
Email,
|
||||
Attachment,
|
||||
Content,
|
||||
ApiKey,
|
||||
CorrespondentSetting,
|
||||
BarcodeTemplate,
|
||||
InboxDocument,
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
} from './entities';
|
||||
|
||||
const entities = [
|
||||
Client,
|
||||
DocumentType,
|
||||
DocumentField,
|
||||
Task,
|
||||
Postprocessing,
|
||||
PostprocessingAction,
|
||||
PostprocessingLog,
|
||||
ExportTarget,
|
||||
Setting,
|
||||
Kontonummer,
|
||||
Document,
|
||||
UserClient,
|
||||
Email,
|
||||
Attachment,
|
||||
Content,
|
||||
ApiKey,
|
||||
CorrespondentSetting,
|
||||
BarcodeTemplate,
|
||||
InboxDocument,
|
||||
InboxPostprocessingAction,
|
||||
CorrespondentEmailMapping,
|
||||
UserSettings,
|
||||
LabelPrintJob,
|
||||
];
|
||||
import { dataSourceOptions, entities } from './data-source';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService) => ({
|
||||
type: 'mysql' as const,
|
||||
host: config.get<string>('DB_HOST', 'localhost'),
|
||||
port: config.get<number>('DB_PORT', 3306),
|
||||
username: config.get<string>('DB_USERNAME', 'root'),
|
||||
password: config.get<string>('DB_PASSWORD', ''),
|
||||
database: config.get<string>('DB_DATABASE', 'paperlessadd'),
|
||||
entities,
|
||||
synchronize: config.get<string>('NODE_ENV') !== 'production',
|
||||
charset: 'utf8mb4',
|
||||
}),
|
||||
}),
|
||||
// Eine einzige Konfigurationsquelle (data-source.ts), geteilt zwischen
|
||||
// NestJS-Laufzeit und TypeORM-CLI (Migrationen).
|
||||
TypeOrmModule.forRoot(dataSourceOptions),
|
||||
TypeOrmModule.forFeature(entities),
|
||||
],
|
||||
exports: [TypeOrmModule],
|
||||
|
||||
@@ -21,3 +21,4 @@ export { InboxPostprocessingAction } from './inbox-postprocessing-action.entity'
|
||||
export { CorrespondentEmailMapping } from './correspondent-email-mapping.entity';
|
||||
export { UserSettings } from './user-settings.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\``);
|
||||
}
|
||||
}
|
||||
@@ -55,11 +55,21 @@ export class EmailDownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
private createImapClient(): ImapFlow {
|
||||
return new ImapFlow({
|
||||
host: this.configService.get<string>('IMAP_HOST', ''),
|
||||
port: this.configService.get<number>('IMAP_PORT', 993),
|
||||
secure: this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true',
|
||||
auth: {
|
||||
user: this.configService.get<string>('IMAP_USERNAME', ''),
|
||||
pass: this.configService.get<string>('IMAP_PASSWORD', ''),
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchAndStore(): Promise<void> {
|
||||
const host = this.configService.get<string>('IMAP_HOST');
|
||||
const port = this.configService.get<number>('IMAP_PORT', 993);
|
||||
const secure =
|
||||
this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true';
|
||||
const user = this.configService.get<string>('IMAP_USERNAME');
|
||||
const pass = this.configService.get<string>('IMAP_PASSWORD');
|
||||
|
||||
@@ -72,16 +82,10 @@ export class EmailDownloadService {
|
||||
|
||||
this.logger.log('E-Mail Fetch Job gestartet.');
|
||||
|
||||
const client = new ImapFlow({
|
||||
host,
|
||||
port,
|
||||
secure,
|
||||
auth: { user, pass },
|
||||
logger: false,
|
||||
});
|
||||
const client = this.createImapClient();
|
||||
|
||||
await client.connect();
|
||||
this.logger.log(`Verbunden mit IMAP-Server ${host}:${port}`);
|
||||
this.logger.log(`Verbunden mit IMAP-Server ${host}.`);
|
||||
|
||||
const lock = await client.getMailboxLock('INBOX');
|
||||
try {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Task } from '../database/entities/task.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import * as QRCode from 'qrcode';
|
||||
import { EmailPageCacheService } from './email-page-cache.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { PdfService } from '../preprocessing/pdf.service';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -52,6 +53,7 @@ export class EmailImportService {
|
||||
private readonly paperlessService: PaperlessService,
|
||||
private readonly pdfService: PdfService,
|
||||
private readonly pageCache: EmailPageCacheService,
|
||||
private readonly imapFolderService: ImapFolderService,
|
||||
) {}
|
||||
|
||||
async ensurePreviews(emailId: number): Promise<void> {
|
||||
@@ -646,6 +648,18 @@ export class EmailImportService {
|
||||
this.logger.log(
|
||||
`Email ${firstAtt.EmailMessageId} als verarbeitet markiert.`,
|
||||
);
|
||||
const emailEntity = await this.emailRepo.findOne({
|
||||
where: { Id: firstAtt.EmailMessageId },
|
||||
});
|
||||
if (emailEntity) {
|
||||
this.imapFolderService
|
||||
.moveToImportiert(emailEntity.MessageId)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
'IMAP-Verschieben fehlgeschlagen: ' + err.message,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import { Email } from '../database/entities/email.entity';
|
||||
import { Attachment } from '../database/entities/attachment.entity';
|
||||
import { Content } from '../database/entities/content.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
|
||||
@@ -31,6 +32,7 @@ export class EmailController {
|
||||
@InjectRepository(Content)
|
||||
private readonly contentRepo: Repository<Content>,
|
||||
private readonly paperlessService: PaperlessService,
|
||||
private readonly imapFolderService: ImapFolderService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -202,7 +204,28 @@ export class EmailController {
|
||||
this.logger.log(
|
||||
`Prüfung abgeschlossen. ${updatedCount} E-Mails aktualisiert, ${idsUpdated} Paperless-IDs ergänzt, ${skippedCount} übersprungen.`,
|
||||
);
|
||||
return { updatedCount, idsUpdated };
|
||||
this.imapFolderService
|
||||
.cleanupImportedEmails()
|
||||
.catch((err) =>
|
||||
this.logger.error('IMAP-Cleanup fehlgeschlagen: ' + err.message),
|
||||
);
|
||||
|
||||
let movedToImportiert = 0;
|
||||
if (body.includeProcessed) {
|
||||
const processedEmails = await this.emailRepo.find({
|
||||
where: [{ Status: 1 }, { Status: 3 }],
|
||||
select: ['MessageId'],
|
||||
});
|
||||
const messageIds = processedEmails
|
||||
.map((e) => e.MessageId)
|
||||
.filter(Boolean);
|
||||
movedToImportiert =
|
||||
await this.imapFolderService.moveProcessedInboxToImportiert(
|
||||
messageIds,
|
||||
);
|
||||
}
|
||||
|
||||
return { updatedCount, idsUpdated, movedToImportiert };
|
||||
} catch (error: any) {
|
||||
this.logger.error(
|
||||
`Kritischer Fehler bei checkAttachments: ${error.message}`,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { EmailPageCacheService } from './email-page-cache.service';
|
||||
|
||||
import { EmailImportController } from './email-import.controller';
|
||||
import { EmailImportService } from './email-import.service';
|
||||
import { ImapFolderService } from './imap-folder.service';
|
||||
import { CorrespondentEmailMapping } from '../database/entities/correspondent-email-mapping.entity';
|
||||
import { Task } from '../database/entities/task.entity';
|
||||
import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
||||
@@ -26,7 +27,7 @@ import { PreprocessingModule } from '../preprocessing/preprocessing.module';
|
||||
PreprocessingModule,
|
||||
],
|
||||
controllers: [EmailController, EmailImportController],
|
||||
providers: [EmailImportService, EmailPageCacheService],
|
||||
providers: [EmailImportService, EmailPageCacheService, ImapFolderService],
|
||||
exports: [EmailPageCacheService],
|
||||
})
|
||||
export class EmailModule {}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ImapFlow } from 'imapflow';
|
||||
|
||||
@Injectable()
|
||||
export class ImapFolderService {
|
||||
private readonly logger = new Logger(ImapFolderService.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
private createClient(): ImapFlow {
|
||||
return new ImapFlow({
|
||||
host: this.configService.get<string>('IMAP_HOST', ''),
|
||||
port: this.configService.get<number>('IMAP_PORT', 993),
|
||||
secure: this.configService.get<string>('IMAP_USE_SSL', 'true') === 'true',
|
||||
auth: {
|
||||
user: this.configService.get<string>('IMAP_USERNAME', ''),
|
||||
pass: this.configService.get<string>('IMAP_PASSWORD', ''),
|
||||
},
|
||||
logger: false,
|
||||
});
|
||||
}
|
||||
|
||||
@Cron('0 3 * * *', { timeZone: 'Europe/Berlin' })
|
||||
async cleanupImportedEmails(): Promise<void> {
|
||||
if (!this.configService.get<string>('IMAP_HOST')) return;
|
||||
const importedFolder = this.configService.get<string>(
|
||||
'IMAP_IMPORTED_FOLDER',
|
||||
'importiert',
|
||||
);
|
||||
const trashFolder = this.configService.get<string>(
|
||||
'IMAP_TRASH_FOLDER',
|
||||
'Trash',
|
||||
);
|
||||
const client = this.createClient();
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
// E-Mails älter als 90 Tage in Papierkorb verschieben
|
||||
try {
|
||||
await client.mailboxOpen(importedFolder);
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - 90);
|
||||
const oldUids = await client.search({ before: cutoff }, { uid: true });
|
||||
if (Array.isArray(oldUids) && oldUids.length > 0) {
|
||||
await client.messageMove(oldUids, trashFolder, { uid: true });
|
||||
this.logger.log(
|
||||
`${oldUids.length} alte E-Mail(s) aus "${importedFolder}" in "${trashFolder}" verschoben.`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(
|
||||
`Bereinigung "${importedFolder}" nicht möglich: ${err.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Papierkorb leeren
|
||||
try {
|
||||
await client.mailboxOpen(trashFolder);
|
||||
const trashUids = await client.search({ all: true }, { uid: true });
|
||||
if (Array.isArray(trashUids) && trashUids.length > 0) {
|
||||
await client.messageDelete(trashUids, { uid: true });
|
||||
this.logger.log(
|
||||
`${trashUids.length} E-Mail(s) aus "${trashFolder}" gelöscht.`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(
|
||||
`Papierkorb "${trashFolder}" konnte nicht geleert werden: ${err.message}`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`IMAP-Cleanup fehlgeschlagen: ${err.message}`);
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async moveProcessedInboxToImportiert(messageIds: string[]): Promise<number> {
|
||||
if (!this.configService.get<string>('IMAP_HOST') || messageIds.length === 0)
|
||||
return 0;
|
||||
|
||||
const importedFolder = this.configService.get<string>(
|
||||
'IMAP_IMPORTED_FOLDER',
|
||||
'importiert',
|
||||
);
|
||||
const client = this.createClient();
|
||||
let movedCount = 0;
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const mailboxes = await client.list();
|
||||
if (!mailboxes.some((m) => m.path === importedFolder)) {
|
||||
await client.mailboxCreate(importedFolder);
|
||||
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
|
||||
}
|
||||
|
||||
const status = await client.mailboxOpen('INBOX');
|
||||
if (status.exists === 0) return 0;
|
||||
|
||||
const normalize = (id: string) => id.replace(/^<|>$/g, '').toLowerCase();
|
||||
const idSet = new Set(messageIds.map(normalize));
|
||||
const uidsToMove: number[] = [];
|
||||
|
||||
for await (const msg of client.fetch('1:*', {
|
||||
uid: true,
|
||||
envelope: true,
|
||||
})) {
|
||||
const msgId = msg.envelope?.messageId;
|
||||
if (msgId && idSet.has(normalize(msgId))) {
|
||||
uidsToMove.push(msg.uid);
|
||||
}
|
||||
}
|
||||
|
||||
if (uidsToMove.length > 0) {
|
||||
await client.messageMove(uidsToMove, importedFolder, { uid: true });
|
||||
movedCount = uidsToMove.length;
|
||||
this.logger.log(
|
||||
`${movedCount} E-Mail(s) aus INBOX → "${importedFolder}" verschoben.`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`moveProcessedInboxToImportiert fehlgeschlagen: ${err.message}`,
|
||||
);
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
|
||||
return movedCount;
|
||||
}
|
||||
|
||||
async moveToImportiert(messageId: string): Promise<void> {
|
||||
if (!this.configService.get<string>('IMAP_HOST')) return;
|
||||
|
||||
const importedFolder = this.configService.get<string>(
|
||||
'IMAP_IMPORTED_FOLDER',
|
||||
'importiert',
|
||||
);
|
||||
const client = this.createClient();
|
||||
try {
|
||||
await client.connect();
|
||||
|
||||
const mailboxes = await client.list();
|
||||
if (!mailboxes.some((m) => m.path === importedFolder)) {
|
||||
await client.mailboxCreate(importedFolder);
|
||||
this.logger.log(`IMAP-Ordner "${importedFolder}" erstellt.`);
|
||||
}
|
||||
|
||||
await client.mailboxOpen('INBOX');
|
||||
const uids = await client.search(
|
||||
{ header: { 'message-id': messageId } },
|
||||
{ uid: true },
|
||||
);
|
||||
if (Array.isArray(uids) && uids.length > 0) {
|
||||
await client.messageMove(uids, importedFolder, { uid: true });
|
||||
this.logger.log(
|
||||
`E-Mail ${messageId} → "${importedFolder}" verschoben.`,
|
||||
);
|
||||
} else {
|
||||
this.logger.warn(
|
||||
`E-Mail ${messageId} nicht in INBOX gefunden (bereits verschoben?).`,
|
||||
);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error(`IMAP moveToImportiert fehlgeschlagen: ${err.message}`);
|
||||
} finally {
|
||||
await client.logout().catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
@@ -8,6 +7,8 @@ import { DocumentType } from '../database/entities/document-type.entity';
|
||||
import { PaperlessService } from './paperless.service';
|
||||
import { PostprocessingService } from '../postprocessing/postprocessing.service';
|
||||
|
||||
const PAPERLESSMANAGER_TAG_ID = 16; // Tag "paperlessmanager"
|
||||
|
||||
@Injectable()
|
||||
export class PaperlessProcessorService {
|
||||
private readonly logger = new Logger(PaperlessProcessorService.name);
|
||||
@@ -22,11 +23,13 @@ export class PaperlessProcessorService {
|
||||
private readonly docFieldRepo: Repository<DocumentField>,
|
||||
) {}
|
||||
|
||||
@Cron(process.env.PAPERLESS_PROCESSOR_CRON || '0 * * * * *')
|
||||
// Manueller Batch-Lauf ("alle paperlessmanager-Dokumente neu durchlaufen").
|
||||
// Die laufende Verarbeitung erfolgt ereignisgesteuert über den Webhook
|
||||
// (processDocumentById), daher ist hier kein @Cron-Trigger mehr gesetzt.
|
||||
async processDocuments() {
|
||||
try {
|
||||
const response = await this.paperlessService.getDocuments({
|
||||
tags__id__all: 16,
|
||||
tags__id__all: PAPERLESSMANAGER_TAG_ID,
|
||||
page_size: 9999,
|
||||
});
|
||||
const documents: any[] = Array.isArray(response)
|
||||
@@ -38,17 +41,12 @@ export class PaperlessProcessorService {
|
||||
const validFieldIds = new Set(customFields.map((f: any) => f.id));
|
||||
|
||||
this.logger.log(
|
||||
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: 16).`,
|
||||
`Verarbeite ${documents.length} Dokument(e) mit Tag "paperlessmanager" (ID: ${PAPERLESSMANAGER_TAG_ID}).`,
|
||||
);
|
||||
|
||||
for (const doc of documents) {
|
||||
try {
|
||||
const updatedDoc = await this.processSingleDocument(
|
||||
doc,
|
||||
validFieldIds,
|
||||
);
|
||||
// Postprocessing nach dem Speichern evaluieren
|
||||
await this.postprocessingService.evaluate(updatedDoc || doc);
|
||||
await this.processAndEvaluate(doc, validFieldIds);
|
||||
} catch (innerErr: any) {
|
||||
this.logger.error(
|
||||
`Fehler bei Dokument ID ${doc.id}: ${innerErr.message}`,
|
||||
@@ -65,6 +63,38 @@ export class PaperlessProcessorService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet ein einzelnes Dokument anhand seiner ID – die ereignisgesteuerte
|
||||
* Variante des früheren Cron-Jobs (vom Paperless-Webhook aufgerufen). Es wird
|
||||
* jedes gemeldete Dokument verarbeitet, unabhängig vom Tag "paperlessmanager".
|
||||
*/
|
||||
async processDocumentById(
|
||||
documentId: number,
|
||||
): Promise<{ processed: boolean; reason?: string }> {
|
||||
const doc = await this.paperlessService.getDocument(documentId);
|
||||
|
||||
const customFields = await this.paperlessService.getCustomFields();
|
||||
const validFieldIds = new Set<number>(customFields.map((f: any) => f.id));
|
||||
|
||||
await this.processAndEvaluate(doc, validFieldIds);
|
||||
this.logger.log(`Dokument ${documentId} per Webhook verarbeitet.`);
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reichert ein Dokument an (Tags, Pflichtfelder, Titel) und evaluiert
|
||||
* anschließend das Postprocessing. Gemeinsame Logik von Batch-Lauf und Webhook.
|
||||
*/
|
||||
private async processAndEvaluate(
|
||||
doc: any,
|
||||
validFieldIds: Set<number>,
|
||||
): Promise<any> {
|
||||
const updatedDoc = await this.processSingleDocument(doc, validFieldIds);
|
||||
// Postprocessing nach dem Speichern evaluieren
|
||||
await this.postprocessingService.evaluate(updatedDoc || doc);
|
||||
return updatedDoc;
|
||||
}
|
||||
|
||||
private async processSingleDocument(
|
||||
doc: any,
|
||||
validFieldIds: Set<number>,
|
||||
@@ -151,7 +181,7 @@ export class PaperlessProcessorService {
|
||||
doc.correspondent !== null && doc.correspondent !== undefined;
|
||||
break;
|
||||
case 2:
|
||||
isFilled = !!doc.created || !!doc.created_date;
|
||||
isFilled = !!doc.created;
|
||||
break;
|
||||
case 3:
|
||||
isFilled =
|
||||
|
||||
@@ -155,7 +155,7 @@ export class PaperlessController {
|
||||
asn: doc.archive_serial_number,
|
||||
documentType: doc.document_type,
|
||||
correspondent: doc.correspondent,
|
||||
created: doc.created_date,
|
||||
created: doc.created,
|
||||
added: doc.added,
|
||||
tags: doc.tags,
|
||||
customFields: doc.custom_fields,
|
||||
@@ -179,7 +179,7 @@ export class PaperlessController {
|
||||
asn: doc.archive_serial_number,
|
||||
documentType: doc.document_type,
|
||||
correspondent: doc.correspondent,
|
||||
created: doc.created_date,
|
||||
created: doc.created,
|
||||
added: doc.added,
|
||||
tags: doc.tags,
|
||||
customFields: doc.custom_fields,
|
||||
@@ -338,7 +338,7 @@ export class PaperlessController {
|
||||
docDate.getHours() * 60 * 60 * 1000,
|
||||
);
|
||||
}
|
||||
oldDocument.created_date = docDate.toISOString().split('T')[0];
|
||||
oldDocument.created = docDate.toISOString();
|
||||
}
|
||||
|
||||
const cfDefinitions = await this.paperlessService.getCustomFields();
|
||||
@@ -390,7 +390,7 @@ export class PaperlessController {
|
||||
for (const req of reqs) {
|
||||
let isFieldValid = false;
|
||||
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)
|
||||
isFieldValid = oldDocument.archive_serial_number !== null;
|
||||
if (req.Type === 4)
|
||||
@@ -446,10 +446,8 @@ export class PaperlessController {
|
||||
);
|
||||
}
|
||||
}
|
||||
titleTemplate = titleTemplate.replace(
|
||||
'{{DATE}}',
|
||||
oldDocument.created_date,
|
||||
);
|
||||
const createdDatePart = String(oldDocument.created ?? '').split('T')[0];
|
||||
titleTemplate = titleTemplate.replace('{{DATE}}', createdDatePart);
|
||||
oldDocument.title = titleTemplate;
|
||||
}
|
||||
} else {
|
||||
@@ -471,6 +469,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);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -32,6 +32,6 @@ import { AuthModule } from '../auth/auth.module';
|
||||
PaperlessProcessorService,
|
||||
PaperlessTaskProcessorService,
|
||||
],
|
||||
exports: [PaperlessService],
|
||||
exports: [PaperlessService, PaperlessProcessorService],
|
||||
})
|
||||
export class PaperlessModule {}
|
||||
|
||||
@@ -250,6 +250,14 @@ export class PaperlessService {
|
||||
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> {
|
||||
const response = await this.client.get('/documents/', {
|
||||
params: { checksum__iexact: checksum },
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
// 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: number[] = [];
|
||||
return {
|
||||
store,
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
insert: () => ({
|
||||
into: () => ({
|
||||
values: (v: { documentId: number }) => ({
|
||||
orIgnore: () => ({
|
||||
execute: () => {
|
||||
const added = !store.includes(v.documentId);
|
||||
if (added) store.push(v.documentId);
|
||||
return Promise.resolve({
|
||||
raw: { affectedRows: added ? 1 : 0 },
|
||||
});
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})),
|
||||
find: jest.fn(() =>
|
||||
Promise.resolve(
|
||||
store.length
|
||||
? [{ documentId: store[0], action: null, createdAt: new Date() }]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
delete: jest.fn((criteria: { documentId: number }) => {
|
||||
const idx = store.indexOf(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).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).toEqual([]);
|
||||
});
|
||||
|
||||
it('entfernt die ID vor Verarbeitungsbeginn aus der Tabelle', async () => {
|
||||
let containedWhileProcessing = true;
|
||||
processor.processDocumentById.mockImplementation((id: number) => {
|
||||
containedWhileProcessing = queueRepo.store.includes(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).toEqual([]);
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { 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;
|
||||
|
||||
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).
|
||||
*
|
||||
* 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 Einträge vorhanden sind, einzeln und sequenziell abarbeiten.
|
||||
for (;;) {
|
||||
// Ältesten Eintrag (FIFO) holen ...
|
||||
const [next] = await this.queueRepo.find({
|
||||
order: { createdAt: 'ASC' },
|
||||
take: 1,
|
||||
});
|
||||
if (!next) break;
|
||||
// ... und 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,85 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Body,
|
||||
Logger,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
UseGuards,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Public } from '../auth/public.decorator';
|
||||
import { ApiKeyGuard } from '../auth/api-key.guard';
|
||||
import { WebhookQueueService } from './webhook-queue.service';
|
||||
|
||||
export interface PaperlessWebhookPayload {
|
||||
document_id: number;
|
||||
action: string;
|
||||
doc_url?: string;
|
||||
document_id?: number; // optional, für manuelles Testen
|
||||
action?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@Controller('webhook')
|
||||
@Controller('api/webhook')
|
||||
export class WebhookController {
|
||||
private readonly logger = new Logger(WebhookController.name);
|
||||
|
||||
@Public()
|
||||
constructor(private readonly webhookQueue: WebhookQueueService) {}
|
||||
|
||||
@UseGuards(ApiKeyGuard)
|
||||
@Post('paperless')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async handlePaperlessWebhook(
|
||||
@Body() payload: PaperlessWebhookPayload,
|
||||
): Promise<{ status: string }> {
|
||||
async handlePaperlessWebhook(@Body() payload: PaperlessWebhookPayload) {
|
||||
const documentId = this.extractDocumentId(payload);
|
||||
if (!documentId) {
|
||||
this.logger.warn(
|
||||
`Webhook ohne ermittelbare Dokument-ID: ${JSON.stringify(payload)}`,
|
||||
);
|
||||
await this.webhookQueue.recordStatus({
|
||||
documentId: null,
|
||||
action: payload.action,
|
||||
status: 'bad-request',
|
||||
});
|
||||
throw new BadRequestException(
|
||||
'Keine Dokument-ID aus doc_url/document_id ermittelbar',
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Webhook empfangen: action=${payload.action}, document=${payload.document_id}`,
|
||||
`Webhook: action=${payload.action}, document=${documentId} → eingereiht`,
|
||||
);
|
||||
// Sofort einreihen und antworten; die eigentliche Verarbeitung übernimmt
|
||||
// der separate Queue-Prozess (sequenziell, dedupliziert).
|
||||
await this.webhookQueue.enqueue(documentId, payload.action);
|
||||
return { status: 'queued', documentId };
|
||||
}
|
||||
|
||||
// TODO: Business-Logik für verschiedene Webhook-Events
|
||||
// - document_updated → Felder prüfen, Postprocessing auslösen
|
||||
// - document_consumed → GoBD-Archivierung prüfen
|
||||
/**
|
||||
* Liefert Informationen zum letzten Webhook-Vorgang (Zeitpunkt, Dokument,
|
||||
* Ergebnis) und die aktuelle Warteschlangen-Größe. Über die globalen Guards
|
||||
* per JWT oder API-Key zugänglich.
|
||||
*/
|
||||
@Get('status')
|
||||
async getWebhookStatus(): Promise<{ lastCall: unknown; queueSize: number }> {
|
||||
return this.webhookQueue.getStatus();
|
||||
}
|
||||
|
||||
return { status: 'received' };
|
||||
/**
|
||||
* Ermittelt die Dokument-ID aus dem Webhook-Payload. Bevorzugt wird das
|
||||
* optionale Feld `document_id` (für manuelles Testen), andernfalls wird die
|
||||
* ID aus dem Paperless-Platzhalter `{{doc_url}}` extrahiert
|
||||
* (z.B. ".../documents/123/").
|
||||
*/
|
||||
private extractDocumentId(payload: PaperlessWebhookPayload): number | null {
|
||||
if (
|
||||
payload?.document_id != null &&
|
||||
!Number.isNaN(Number(payload.document_id))
|
||||
) {
|
||||
return Number(payload.document_id);
|
||||
}
|
||||
if (typeof payload?.doc_url === 'string') {
|
||||
const m = payload.doc_url.match(/\/documents\/(\d+)/);
|
||||
if (m) return Number(m[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { WebhookController } from './webhook.controller';
|
||||
import { WebhookQueueService } from './webhook-queue.service';
|
||||
import { PaperlessModule } from '../paperless/paperless.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { Setting } from '../database/entities/setting.entity';
|
||||
import { WebhookQueueItem } from '../database/entities/webhook-queue-item.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Setting, WebhookQueueItem]),
|
||||
PaperlessModule,
|
||||
AuthModule,
|
||||
],
|
||||
controllers: [WebhookController],
|
||||
providers: [WebhookQueueService],
|
||||
})
|
||||
export class WebhookModule {}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Controller, Get, Put, Param, Body, Query } from '@nestjs/common';
|
||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||
import { Permission } from '../auth/permissions.enum';
|
||||
import { ZahlungService, type ZahlungFilter } from './zahlung.service';
|
||||
|
||||
@Controller('api/zahlung')
|
||||
@RequirePermissions(Permission.VIEW_ZAHLUNG)
|
||||
export class ZahlungController {
|
||||
constructor(private readonly zahlungService: ZahlungService) {}
|
||||
|
||||
@Get('documents')
|
||||
getDocuments(
|
||||
@Query('page') page = '1',
|
||||
@Query('pageSize') pageSize = '25',
|
||||
@Query('filter') filter: ZahlungFilter = 'ausstehend',
|
||||
) {
|
||||
return this.zahlungService.getZahlungDocuments(
|
||||
parseInt(page, 10),
|
||||
Math.min(parseInt(pageSize, 10), 100),
|
||||
filter,
|
||||
);
|
||||
}
|
||||
|
||||
@Put('documents/:id/zahlung')
|
||||
setZahlung(@Param('id') id: string, @Body('value') value: string | null) {
|
||||
return this.zahlungService.setZahlung(parseInt(id, 10), value ?? null);
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
getOptions() {
|
||||
return this.zahlungService.getZahlungOptions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { DocumentType } from '../database/entities/document-type.entity';
|
||||
import { PaperlessModule } from '../paperless/paperless.module';
|
||||
import { ZahlungController } from './zahlung.controller';
|
||||
import { ZahlungService } from './zahlung.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([DocumentType]), PaperlessModule],
|
||||
controllers: [ZahlungController],
|
||||
providers: [ZahlungService],
|
||||
})
|
||||
export class ZahlungModule {}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Injectable, Logger, ForbiddenException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DocumentType } from '../database/entities/document-type.entity';
|
||||
import { PaperlessService } from '../paperless/paperless.service';
|
||||
|
||||
const FREIGABE_FIELD_ID = 15;
|
||||
const ZAHLUNG_FIELD_ID = 16;
|
||||
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
||||
|
||||
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
|
||||
|
||||
@Injectable()
|
||||
export class ZahlungService {
|
||||
private readonly logger = new Logger(ZahlungService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(DocumentType)
|
||||
private readonly documentTypeRepo: Repository<DocumentType>,
|
||||
private readonly paperlessService: PaperlessService,
|
||||
) {}
|
||||
|
||||
async getZahlungDocuments(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
filter: ZahlungFilter,
|
||||
) {
|
||||
const docTypes = await this.documentTypeRepo.find({
|
||||
where: { FreigabeErforderlich: true as any },
|
||||
});
|
||||
|
||||
if (docTypes.length === 0) {
|
||||
return { count: 0, results: [] };
|
||||
}
|
||||
|
||||
const docTypeIds = docTypes.map((dt) => dt.DocumentTypeId).join(',');
|
||||
|
||||
const params: Record<string, any> = {
|
||||
page: 1,
|
||||
page_size: 9999,
|
||||
document_type__id__in: docTypeIds,
|
||||
ordering: '-created',
|
||||
truncate_content: true,
|
||||
};
|
||||
|
||||
let allDocs: any[];
|
||||
try {
|
||||
const result = await this.paperlessService.getDocuments(params);
|
||||
allDocs = result.results ?? [];
|
||||
} catch (err: any) {
|
||||
this.logger.warn(
|
||||
'Fehler beim Laden der Belege für Zahlung',
|
||||
err?.message,
|
||||
);
|
||||
return { count: 0, results: [] };
|
||||
}
|
||||
|
||||
const filtered = this.applyFilter(allDocs, filter);
|
||||
const start = (page - 1) * pageSize;
|
||||
return {
|
||||
count: filtered.length,
|
||||
results: filtered.slice(start, start + pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
private applyFilter(docs: any[], filter: ZahlungFilter): any[] {
|
||||
if (filter === 'alle') return docs;
|
||||
|
||||
return docs.filter((doc: any) => {
|
||||
const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID);
|
||||
const istFreigegeben = freigabeValue === FREIGABE_WERT_FREIGEGEBEN;
|
||||
|
||||
if (filter === 'freigegeben') return istFreigegeben;
|
||||
|
||||
// 'ausstehend': freigegeben aber noch nicht bezahlt
|
||||
const zahlungValue = this.getCfValue(doc, ZAHLUNG_FIELD_ID);
|
||||
return istFreigegeben && !zahlungValue;
|
||||
});
|
||||
}
|
||||
|
||||
private getCfValue(doc: any, fieldId: number): string | null {
|
||||
const cf = (doc.custom_fields ?? []).find((f: any) => f.field === fieldId);
|
||||
if (!cf || cf.value === null || cf.value === undefined || cf.value === '')
|
||||
return null;
|
||||
if (typeof cf.value === 'object') {
|
||||
return (
|
||||
String(cf.value?.id ?? cf.value?.value ?? cf.value?.label ?? '') || null
|
||||
);
|
||||
}
|
||||
return String(cf.value);
|
||||
}
|
||||
|
||||
async setZahlung(documentId: number, value: string | null) {
|
||||
const doc = await this.paperlessService.getDocument(documentId);
|
||||
|
||||
const freigabeValue = this.getCfValue(doc, FREIGABE_FIELD_ID);
|
||||
if (freigabeValue !== FREIGABE_WERT_FREIGEGEBEN) {
|
||||
throw new ForbiddenException(
|
||||
'Zahlung kann nur für freigegebene Belege gesetzt werden',
|
||||
);
|
||||
}
|
||||
|
||||
const customFields: any[] = [...(doc.custom_fields ?? [])];
|
||||
const existing = customFields.find(
|
||||
(f: any) => f.field === ZAHLUNG_FIELD_ID,
|
||||
);
|
||||
if (existing) {
|
||||
existing.value = value;
|
||||
} else if (value !== null && value !== '') {
|
||||
customFields.push({ field: ZAHLUNG_FIELD_ID, value });
|
||||
}
|
||||
|
||||
await this.paperlessService.updateDocument(documentId, {
|
||||
custom_fields: customFields,
|
||||
});
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async getZahlungOptions(): Promise<{ id: string; label: string }[]> {
|
||||
const fields = await this.paperlessService.getCustomFields();
|
||||
const field = fields.find((f: any) => f.id === ZAHLUNG_FIELD_ID);
|
||||
if (!field) return [];
|
||||
|
||||
const rawOptions: any[] = field.extra_data?.select_options ?? [];
|
||||
return rawOptions
|
||||
.filter((o) => o !== null && o !== undefined && o !== '')
|
||||
.map((o) => {
|
||||
if (typeof o === 'object') {
|
||||
return {
|
||||
id: String(o.id ?? o.value ?? o.label ?? ''),
|
||||
label: String(o.label ?? o.name ?? o.id ?? ''),
|
||||
};
|
||||
}
|
||||
return { id: String(o), label: String(o) };
|
||||
})
|
||||
.filter((o) => o.id !== '');
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ const UserSettingsPage = lazy(() => import('./pages/UserSettingsPage'));
|
||||
const LoginPage = lazy(() => import('./pages/LoginPage'));
|
||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'));
|
||||
const FreigabePage = lazy(() => import('./pages/FreigabePage'));
|
||||
const ZahlungPage = lazy(() => import('./pages/ZahlungPage'));
|
||||
import { Permission } from './auth/permissions';
|
||||
|
||||
function UnauthorizedPage() {
|
||||
@@ -75,6 +76,10 @@ function ThemedApp() {
|
||||
token: {
|
||||
colorPrimary: '#1677ff',
|
||||
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
|
||||
? {}
|
||||
: {
|
||||
@@ -133,6 +138,7 @@ function ThemedApp() {
|
||||
<Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} />
|
||||
<Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} />
|
||||
<Route path="/freigabe" element={<PermissionRoute permission={Permission.VIEW_FREIGABE}><FreigabePage /></PermissionRoute>} />
|
||||
<Route path="/zahlung" element={<PermissionRoute permission={Permission.VIEW_ZAHLUNG}><ZahlungPage /></PermissionRoute>} />
|
||||
<Route path="/user-settings" element={<UserSettingsPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import axios from 'axios';
|
||||
import { getAccessToken } from '../auth/oidc';
|
||||
import axios, { type InternalAxiosRequestConfig } from 'axios';
|
||||
import { getAccessToken, renewToken } from '../auth/oidc';
|
||||
import { triggerLoginRedirect } from '../auth/sessionRedirect';
|
||||
import { getEnv } from '../utils/env';
|
||||
|
||||
// Markiert eine bereits einmal wiederholte Anfrage, um Endlosschleifen zu
|
||||
// vermeiden.
|
||||
type RetryConfig = InternalAxiosRequestConfig & { _retried?: boolean };
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: getEnv('VITE_API_URL') || '',
|
||||
timeout: 30000,
|
||||
@@ -19,7 +23,18 @@ api.interceptors.request.use(async (config) => {
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
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();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
|
||||
@@ -44,7 +44,7 @@ export const emailsApi = {
|
||||
api.post<{ message: string }>('/api/emails/fetch').then((r) => r.data),
|
||||
|
||||
checkAttachments: (includeProcessed = false) =>
|
||||
api.post<{ updatedCount: number; idsUpdated: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data),
|
||||
api.post<{ updatedCount: number; idsUpdated: number; movedToImportiert: number }>('/api/emails/check-attachments', { includeProcessed }).then((r) => r.data),
|
||||
|
||||
updateStatus: (id: number, status: number) =>
|
||||
api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data),
|
||||
|
||||
@@ -4,7 +4,6 @@ export interface FreigabeDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
created: string;
|
||||
created_date: string;
|
||||
correspondent: number | null;
|
||||
document_type: number | null;
|
||||
archive_serial_number: number | null;
|
||||
|
||||
@@ -208,6 +208,8 @@ export interface AgrarmonitorPollingConfig {
|
||||
tagHochgeladen: string;
|
||||
linkField: string;
|
||||
tagManuell: string;
|
||||
importWartezeitMinuten: string;
|
||||
notizMarker: string;
|
||||
}
|
||||
|
||||
export interface AgrarmonitorPollingResult {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import api from './client';
|
||||
|
||||
export interface WebhookLastCall {
|
||||
at: string; // ISO-Zeitstempel
|
||||
documentId: number | null;
|
||||
action: string | null;
|
||||
status: 'queued' | 'processed' | 'error' | 'bad-request' | string;
|
||||
reason?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WebhookStatus {
|
||||
lastCall: WebhookLastCall | null;
|
||||
queueSize?: number;
|
||||
}
|
||||
|
||||
export const webhookApi = {
|
||||
getStatus: () =>
|
||||
api.get<WebhookStatus>('/api/webhook/status').then((r) => r.data),
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import api from './client';
|
||||
|
||||
export type ZahlungFilter = 'ausstehend' | 'freigegeben' | 'alle';
|
||||
|
||||
export interface ZahlungDocument {
|
||||
id: number;
|
||||
title: string;
|
||||
created: string;
|
||||
correspondent: number | null;
|
||||
document_type: number | null;
|
||||
archive_serial_number: number | null;
|
||||
tags: number[];
|
||||
custom_fields: { field: number; value: any }[];
|
||||
}
|
||||
|
||||
export interface ZahlungOption {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ZahlungResult {
|
||||
count: number;
|
||||
results: ZahlungDocument[];
|
||||
}
|
||||
|
||||
export const zahlungApi = {
|
||||
getDocuments: (page = 1, pageSize = 25, filter: ZahlungFilter = 'ausstehend') =>
|
||||
api
|
||||
.get<ZahlungResult>('/api/zahlung/documents', { params: { page, pageSize, filter } })
|
||||
.then((r) => r.data),
|
||||
|
||||
setZahlung: (docId: number, value: string | null) =>
|
||||
api
|
||||
.put<{ success: boolean }>(`/api/zahlung/documents/${docId}/zahlung`, { value })
|
||||
.then((r) => r.data),
|
||||
|
||||
getOptions: () =>
|
||||
api.get<ZahlungOption[]>('/api/zahlung/options').then((r) => r.data),
|
||||
};
|
||||
@@ -34,9 +34,36 @@ export async function getUser(): Promise<User | null> {
|
||||
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> {
|
||||
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 };
|
||||
|
||||
@@ -6,6 +6,7 @@ export const Permission = {
|
||||
VIEW_SCANNER: 'VIEW_SCANNER',
|
||||
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
|
||||
VIEW_FREIGABE: 'VIEW_FREIGABE',
|
||||
VIEW_ZAHLUNG: 'VIEW_ZAHLUNG',
|
||||
} as const;
|
||||
|
||||
export type Permission = typeof Permission[keyof typeof Permission];
|
||||
@@ -26,6 +27,7 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
|
||||
permissions.add(Permission.VIEW_SCANNER);
|
||||
permissions.add(Permission.MANAGE_SETTINGS);
|
||||
permissions.add(Permission.VIEW_FREIGABE);
|
||||
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
@@ -44,6 +46,9 @@ export function mapGroupsToPermissions(groups: string[] | undefined | null): Per
|
||||
if (groups.includes('PM_Freigabe')) {
|
||||
permissions.add(Permission.VIEW_FREIGABE);
|
||||
}
|
||||
if (groups.includes('PM_Zahlung')) {
|
||||
permissions.add(Permission.VIEW_ZAHLUNG);
|
||||
}
|
||||
|
||||
return Array.from(permissions);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
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 { posteingangApi } 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 { AuthIframe, openAuthUrl } from '../utils/auth-resource';
|
||||
import DocumentSearchModal from './DocumentSearchModal';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
@@ -26,6 +27,7 @@ interface Props {
|
||||
|
||||
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -349,8 +351,8 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
title={`Dokument bearbeiten (${document?.title || ''})`}
|
||||
open={open && !kontonummerMissing}
|
||||
onCancel={() => onClose(false)}
|
||||
width={1400}
|
||||
style={{ top: 20 }}
|
||||
width={{ xs: '100vw', md: 900, xl: 1400 }}
|
||||
style={isMobile ? {} : { top: 20 }}
|
||||
footer={
|
||||
hasNextDocument ? [
|
||||
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
|
||||
@@ -363,8 +365,12 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Row gutter={16} style={{ height: '75vh', overflow: 'hidden' }}>
|
||||
<Col span={10} style={{ overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}>
|
||||
<Row gutter={[16, 16]} style={isMobile ? {} : { height: '75vh', overflow: 'hidden' }}>
|
||||
<Col
|
||||
xs={24}
|
||||
lg={10}
|
||||
style={isMobile ? {} : { overflowY: 'auto', paddingRight: '1rem', borderRight: '1px solid #f0f0f0' }}
|
||||
>
|
||||
<Form form={form} layout="vertical" disabled={saving}>
|
||||
|
||||
<Form.Item name="mandant" label="Mandant" rules={[{ required: true, message: 'Wähle einen Mandanten' }]}>
|
||||
@@ -556,10 +562,20 @@ export default function DocumentEditModal({ documentId, document, open, onClose,
|
||||
})}
|
||||
</Form>
|
||||
</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
|
||||
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"
|
||||
/>
|
||||
</Col>
|
||||
|
||||
@@ -57,7 +57,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
|
||||
open={open}
|
||||
onCancel={onCancel}
|
||||
footer={null}
|
||||
width={800}
|
||||
width={{ xs: '100vw', md: 800 }}
|
||||
style={{ top: 50 }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
@@ -100,7 +100,7 @@ export default function DocumentSearchModal({ open, onCancel, onSelect }: Props)
|
||||
description={
|
||||
<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 }}>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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -538,8 +538,8 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
{toProcess.map(item => (
|
||||
<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>
|
||||
<Row gutter={24}>
|
||||
<Col span={8}>
|
||||
<Row gutter={[24, 16]}>
|
||||
<Col xs={24} lg={8}>
|
||||
{/* Eingangsdatum */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text>
|
||||
@@ -626,7 +626,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Col xs={24} lg={16}>
|
||||
<BarcodePositioner
|
||||
attachmentId={item.attachmentId}
|
||||
startPage={item.pages?.start}
|
||||
@@ -666,9 +666,9 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
return (
|
||||
<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>
|
||||
<Row gutter={24} align="middle">
|
||||
<Col span={showPrint ? 20 : 24}>
|
||||
<Space size={24}>
|
||||
<Row gutter={[24, 12]} align="middle">
|
||||
<Col xs={24} md={showPrint ? 20 : 24}>
|
||||
<Space size={24} wrap>
|
||||
<Text type="secondary">
|
||||
Eingangsdatum: <Text strong>{datum?.format('DD.MM.YYYY') ?? '—'}</Text>
|
||||
</Text>
|
||||
@@ -690,7 +690,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
)}
|
||||
</Col>
|
||||
{showPrint && (
|
||||
<Col span={4} style={{ textAlign: 'right' }}>
|
||||
<Col xs={24} md={4} style={{ textAlign: 'right' }}>
|
||||
<Button icon={<PrinterOutlined />} onClick={() => printDocument(main.virtualId, main.attachmentId)}>
|
||||
Drucken
|
||||
</Button>
|
||||
@@ -735,7 +735,7 @@ export default function MailImportWizard({ visible, onClose, onSuccess, email, a
|
||||
title="Paperless Import-Wizard"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width={1000}
|
||||
width={{ xs: '100vw', md: 900, lg: 1000 }}
|
||||
footer={
|
||||
importSuccess ? (
|
||||
<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 {
|
||||
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 { 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 {
|
||||
InboxOutlined,
|
||||
FileTextOutlined,
|
||||
@@ -14,9 +14,12 @@ import {
|
||||
AppstoreOutlined,
|
||||
GlobalOutlined,
|
||||
CheckCircleOutlined,
|
||||
EuroOutlined,
|
||||
MenuOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
import { useTheme } from '../theme/ThemeContext';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import { Permission } from '../auth/permissions';
|
||||
import { statsApi, type StatsCounts } from '../api/stats';
|
||||
|
||||
@@ -40,22 +43,28 @@ const allMenuItems: MenuItemDef[] = [
|
||||
{ key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' },
|
||||
{ key: 'agrarmonitor', icon: <GlobalOutlined />, label: 'In Agrarmonitor', permission: Permission.PROCESS_MANUALLY, countKey: 'agrarmonitor', externalUrl: 'https://admin7.agrarmonitor.de/dateien/eingang#dateien' },
|
||||
{ key: '/freigabe', icon: <CheckCircleOutlined />, label: 'Freigabe', permission: Permission.VIEW_FREIGABE },
|
||||
{ key: '/zahlung', icon: <EuroOutlined />, label: 'Zahlung', permission: Permission.VIEW_ZAHLUNG },
|
||||
{ key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS },
|
||||
];
|
||||
|
||||
export default function AppLayout() {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout, hasPermission, isAuthenticated } = useAuth();
|
||||
const { token: themeToken } = theme.useToken();
|
||||
const { isDark, toggleTheme } = useTheme();
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const [counts, setCounts] = useState<StatsCounts | null>(null);
|
||||
|
||||
// Im Drawer (mobil) ist die Navigation nie eingeklappt
|
||||
const effectiveCollapsed = isMobile ? false : collapsed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
|
||||
const fetchCounts = async () => {
|
||||
try {
|
||||
const data = await statsApi.getCounts();
|
||||
@@ -64,10 +73,10 @@ export default function AppLayout() {
|
||||
console.error('Fehler beim Abrufen der Zählerstände:', err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
fetchCounts();
|
||||
const interval = setInterval(fetchCounts, 30000); // 30 Sekunden Polling
|
||||
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated, location.pathname]); // Update after navigation or auth change
|
||||
|
||||
@@ -78,9 +87,9 @@ export default function AppLayout() {
|
||||
label: (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%', paddingRight: 8 }}>
|
||||
<span>{item.label}</span>
|
||||
{item.countKey && counts && counts[item.countKey] > 0 && !collapsed && (
|
||||
<Badge
|
||||
count={counts[item.countKey]}
|
||||
{item.countKey && counts && counts[item.countKey] > 0 && !effectiveCollapsed && (
|
||||
<Badge
|
||||
count={counts[item.countKey]}
|
||||
overflowCount={99}
|
||||
size="small"
|
||||
color={isDark ? themeToken.colorPrimary : '#1677ff'}
|
||||
@@ -105,158 +114,231 @@ export default function AppLayout() {
|
||||
|
||||
const logoColor = isDark ? '#fff' : '#1a1a2e';
|
||||
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 (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider
|
||||
width={240}
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
theme={isDark ? 'dark' : 'light'}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...siderStyle,
|
||||
}}
|
||||
>
|
||||
{/* Logo / Collapse-Toggle */}
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{
|
||||
height: 56,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
border: 'none',
|
||||
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
padding: 0,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.02)')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Text strong style={{ color: logoColor, fontSize: collapsed ? 14 : 18, transition: 'font-size 0.2s' }}>
|
||||
{collapsed ? 'PM' : 'Paperless'}
|
||||
</Text>
|
||||
</button>
|
||||
{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>
|
||||
|
||||
{/* Navigation Menu */}
|
||||
<Menu
|
||||
<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
|
||||
width={240}
|
||||
trigger={null}
|
||||
collapsible
|
||||
collapsed={collapsed}
|
||||
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,
|
||||
overflow: 'hidden',
|
||||
height: '100vh',
|
||||
position: 'fixed',
|
||||
left: 0,
|
||||
right: 0,
|
||||
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
|
||||
padding: collapsed ? '12px 0' : '12px 16px',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
transition: 'padding 0.2s',
|
||||
...siderStyle,
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
{renderSidebarContent(collapsed)}
|
||||
</Sider>
|
||||
)}
|
||||
|
||||
{/* User Menu */}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'user-settings',
|
||||
icon: <SettingOutlined />,
|
||||
label: 'Benutzereinstellungen',
|
||||
onClick: () => navigate('/user-settings'),
|
||||
},
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: 'Abmelden',
|
||||
onClick: () => logout(),
|
||||
},
|
||||
],
|
||||
}}
|
||||
placement="topRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: collapsed ? '8px 0' : '8px 12px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
justifyContent: collapsed ? 'center' : 'flex-start',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = isDark ? 'rgba(255,255,255,0.08)' : '#eef1f8')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<Avatar size="small" icon={<UserOutlined />} />
|
||||
{!collapsed && (
|
||||
<Text ellipsis style={{ color: subtleColor, fontSize: 13, maxWidth: 120 }}>
|
||||
{user?.profile?.name || 'Benutzer'}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Sider>
|
||||
|
||||
<Layout style={{ marginLeft: collapsed ? 80 : 240, transition: 'margin-left 0.2s' }}>
|
||||
<Content style={{ margin: 24, padding: 24, background: themeToken.colorBgContainer, borderRadius: 8 }}>
|
||||
<Layout
|
||||
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 />
|
||||
</Content>
|
||||
</Layout>
|
||||
|
||||
@@ -7,11 +7,14 @@ import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { freigabeApi, type FreigabeDocument, type FreigabeOption } from '../api/freigabe';
|
||||
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import MobileCardList from '../components/MobileCardList';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
const FREIGABE_FIELD_ID = 15;
|
||||
|
||||
export default function FreigabePage() {
|
||||
const isMobile = useIsMobile();
|
||||
const [data, setData] = useState<FreigabeDocument[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -119,7 +122,7 @@ export default function FreigabePage() {
|
||||
},
|
||||
{
|
||||
title: 'Erstellt',
|
||||
dataIndex: 'created_date',
|
||||
dataIndex: 'created',
|
||||
key: 'created',
|
||||
width: 110,
|
||||
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
||||
@@ -153,44 +156,92 @@ 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 (
|
||||
<>
|
||||
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Freigabe</Title>
|
||||
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
{isMobile ? (
|
||||
<Select
|
||||
style={{ width: '100%', marginBottom: 16 }}
|
||||
value={nurNichtFreigegeben}
|
||||
onChange={(e) => {
|
||||
onChange={(v) => {
|
||||
setPage(1);
|
||||
setNurNichtFreigegeben(e.target.value);
|
||||
setNurNichtFreigegeben(v);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value={true}>Nicht freigegeben</Radio.Button>
|
||||
<Radio.Button value={false}>Alle</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
options={[
|
||||
{ value: true, label: 'Nicht freigegeben' },
|
||||
{ value: false, label: 'Alle' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={nurNichtFreigegeben}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setNurNichtFreigegeben(e.target.value);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value={true}>Nicht freigegeben</Radio.Button>
|
||||
<Radio.Button value={false}>Alle</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
<Table<FreigabeDocument>
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['25', '50', '100'],
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
showTotal: (t) => `${t} Belege`,
|
||||
}}
|
||||
/>
|
||||
{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>
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Freigabe setzen"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { inboxApi, type InboxBarcode, type InboxFile, type PostprocessActionResult } from '../api/inbox';
|
||||
import { paperlessApi } from '../api/paperless';
|
||||
import { userSettingsApi, type SenderOption } from '../api/userSettings';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
const ZOOM_MIN = 0.5;
|
||||
const ZOOM_MAX = 3;
|
||||
@@ -113,6 +114,7 @@ function CompareModal({
|
||||
onCreateNewVersion,
|
||||
onSkip,
|
||||
}: CompareModalProps) {
|
||||
const compareIsMobile = useIsMobile();
|
||||
const [paperlessUrl, setPaperlessUrl] = useState<string | null>(null);
|
||||
const [inboxUrl, setInboxUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -165,8 +167,8 @@ function CompareModal({
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, height: '75vh' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexDirection: compareIsMobile ? 'column' : 'row', gap: 12, height: '75vh' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0, minHeight: 0 }}>
|
||||
<Typography.Text strong style={{ marginBottom: 4 }}>
|
||||
Original (Paperless)
|
||||
</Typography.Text>
|
||||
@@ -180,7 +182,7 @@ function CompareModal({
|
||||
)}
|
||||
</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 }}>
|
||||
Aktueller Abschnitt (Inbox)
|
||||
</Typography.Text>
|
||||
@@ -858,6 +860,7 @@ function SendEmailDialog({ open, fileId, fileName, documents, thumbUrls, onClose
|
||||
export default function InboxDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [file, setFile] = useState<InboxFile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [thumbUrls, setThumbUrls] = useState<Map<number, string>>(new Map());
|
||||
@@ -1211,13 +1214,13 @@ export default function InboxDetailPage() {
|
||||
})();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 12, gap: 12 }}>
|
||||
<Space>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: isMobile ? 'calc(100dvh - 180px)' : 'calc(100vh - 120px)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', rowGap: 8, marginBottom: 12, gap: 12 }}>
|
||||
<Space wrap>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/inbox')}>
|
||||
Zurück
|
||||
</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}
|
||||
</Title>
|
||||
<SourceTag source={file.source} />
|
||||
@@ -1399,12 +1402,13 @@ export default function InboxDetailPage() {
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 140,
|
||||
width: isMobile ? 84 : 140,
|
||||
overflowY: 'auto',
|
||||
padding: 6,
|
||||
background: '#fafafa',
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{sidebarPages.map((n, idx) => {
|
||||
@@ -1428,7 +1432,7 @@ export default function InboxDetailPage() {
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 170,
|
||||
height: isMobile ? 100 : 170,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -1439,7 +1443,7 @@ export default function InboxDetailPage() {
|
||||
<img
|
||||
src={url}
|
||||
alt={`Seite ${docPage}`}
|
||||
style={thumbImageStyle(rotationFor(n), 130)}
|
||||
style={thumbImageStyle(rotationFor(n), isMobile ? 72 : 130)}
|
||||
/>
|
||||
) : (
|
||||
<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 { labelPrintAgentApi } from '../api/labelPrintAgent';
|
||||
import { userSettingsApi } from '../api/userSettings';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import MobileCardList from '../components/MobileCardList';
|
||||
|
||||
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 {
|
||||
if (!barcodes || barcodes.length === 0) {
|
||||
return <Typography.Text type="secondary">—</Typography.Text>;
|
||||
@@ -137,6 +151,7 @@ function buildInitialFieldValues(template: BarcodeTemplate | null): Record<strin
|
||||
|
||||
export default function InboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [files, setFiles] = useState<InboxFile[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -261,6 +276,50 @@ export default function InboxPage() {
|
||||
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
|
||||
);
|
||||
|
||||
// Karten-Ansicht sortiert wie die Tabelle (neueste zuerst)
|
||||
const sortedForMobile = [...filtered].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
||||
|
||||
const renderActions = (record: InboxFile, direction: 'column' | 'row' = 'column') => (
|
||||
<div style={{ display: 'flex', flexDirection: direction, alignItems: 'flex-start', flexWrap: 'wrap' }}>
|
||||
<Tooltip title="Vorschau öffnen">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate(`/inbox/${encodeURIComponent(record.id)}`)}
|
||||
>
|
||||
Weiterverarbeiten
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Dokument löschen?"
|
||||
description="Datei und Datenbank-Eintrag werden dauerhaft entfernt."
|
||||
okText="Löschen"
|
||||
cancelText="Abbrechen"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
Löschen
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Tooltip
|
||||
title={
|
||||
record.source === 'all'
|
||||
? 'In meinen persönlichen Scan-Ordner verschieben'
|
||||
: 'In den gemeinsamen Ordner (Öffentlich) verschieben'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
icon={record.source === 'all' ? <UserOutlined /> : <TeamOutlined />}
|
||||
onClick={() => handleUpdateSource(record.id, record.source)}
|
||||
>
|
||||
{record.source === 'all' ? 'Zu Persönlich' : 'Zu Öffentlich'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
|
||||
const columns: ColumnsType<InboxFile> = [
|
||||
{
|
||||
title: 'Dateiname',
|
||||
@@ -283,16 +342,7 @@ export default function InboxPage() {
|
||||
{ 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>
|
||||
),
|
||||
render: (src: InboxFile['source']) => renderSourceTag(src),
|
||||
},
|
||||
{
|
||||
title: 'QR-Code / Vorlage',
|
||||
@@ -321,46 +371,7 @@ export default function InboxPage() {
|
||||
title: 'Aktionen',
|
||||
key: 'actions',
|
||||
width: 140,
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
|
||||
<Tooltip title="Vorschau öffnen">
|
||||
<Button
|
||||
type="link"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => navigate(`/inbox/${encodeURIComponent(record.id)}`)}
|
||||
>
|
||||
Weiterverarbeiten
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Dokument löschen?"
|
||||
description="Datei und Datenbank-Eintrag werden dauerhaft entfernt."
|
||||
okText="Löschen"
|
||||
cancelText="Abbrechen"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||||
Löschen
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
<Tooltip
|
||||
title={
|
||||
record.source === 'all'
|
||||
? 'In meinen persönlichen Scan-Ordner verschieben'
|
||||
: 'In den gemeinsamen Ordner (Öffentlich) verschieben'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
icon={record.source === 'all' ? <UserOutlined /> : <TeamOutlined />}
|
||||
onClick={() => handleUpdateSource(record.id, record.source)}
|
||||
>
|
||||
{record.source === 'all' ? 'Zu Persönlich' : 'Zu Öffentlich'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
render: (_, record) => renderActions(record),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -371,6 +382,8 @@ export default function InboxPage() {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
@@ -383,11 +396,11 @@ export default function InboxPage() {
|
||||
Scan-Ordner.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Suchen …"
|
||||
style={{ width: 260 }}
|
||||
style={{ width: isMobile ? '100%' : 260 }}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
@@ -478,20 +491,48 @@ export default function InboxPage() {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Card>
|
||||
<Table<InboxFile>
|
||||
{isMobile ? (
|
||||
<MobileCardList<InboxFile>
|
||||
dataSource={sortedForMobile}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 25,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `${t} Dateien`,
|
||||
}}
|
||||
locale={{ emptyText: 'Keine Dateien vorhanden' }}
|
||||
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>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
pageSize: 25,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `${t} Dateien`,
|
||||
}}
|
||||
locale={{ emptyText: 'Keine Dateien vorhanden' }}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import { emailsApi, type EmailItem, type EmailAttachment } from '../api/emails';
|
||||
import { emailImportApi } from '../api/email-import';
|
||||
import { getEnv } from '../utils/env';
|
||||
import MailImportWizard from '../components/MailImportWizard';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
export default function MailDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [email, setEmail] = useState<EmailItem | null>(null);
|
||||
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
|
||||
const [selected, setSelected] = useState<EmailAttachment | null>(null);
|
||||
@@ -136,16 +138,16 @@ export default function MailDetailPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<Space>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8, marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
|
||||
Zurück
|
||||
</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>}
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
|
||||
<Space wrap>
|
||||
<Popconfirm
|
||||
title="E-Mail ignorieren"
|
||||
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
|
||||
@@ -183,12 +185,26 @@ export default function MailDetailPage() {
|
||||
</Space>
|
||||
</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 */}
|
||||
<Card
|
||||
title="E-Mail"
|
||||
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><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div>
|
||||
@@ -208,7 +224,17 @@ export default function MailDetailPage() {
|
||||
</Card>
|
||||
|
||||
{/* 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' }}>
|
||||
<Table<EmailAttachment>
|
||||
columns={columns}
|
||||
@@ -224,7 +250,7 @@ export default function MailDetailPage() {
|
||||
locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
|
||||
/>
|
||||
</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 ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
|
||||
<Spin />
|
||||
|
||||
@@ -7,11 +7,22 @@ import dayjs from 'dayjs';
|
||||
import { emailsApi, type EmailItem } from '../api/emails';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
const [emails, setEmails] = useState<EmailItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
@@ -84,13 +95,7 @@ export default function MailpostfachPage() {
|
||||
dataIndex: 'Status',
|
||||
key: 'Status',
|
||||
width: 110,
|
||||
render: (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>;
|
||||
},
|
||||
render: renderStatusTag,
|
||||
filters: [
|
||||
{ text: 'Neu', value: 0 },
|
||||
{ text: 'Verarbeitet', value: 1 },
|
||||
@@ -116,9 +121,9 @@ export default function MailpostfachPage() {
|
||||
|
||||
return (
|
||||
<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>
|
||||
<Space>
|
||||
<Space wrap>
|
||||
<Button
|
||||
icon={<DownloadOutlined />}
|
||||
onClick={async () => {
|
||||
@@ -172,6 +177,7 @@ export default function MailpostfachPage() {
|
||||
const parts = [];
|
||||
if (result.updatedCount > 0) parts.push(`${result.updatedCount} E-Mail(s) aktualisiert`);
|
||||
if (result.idsUpdated > 0) parts.push(`${result.idsUpdated} Paperless-ID(s) ergänzt`);
|
||||
if (result.movedToImportiert > 0) parts.push(`${result.movedToImportiert} E-Mail(s) in „importiert" verschoben`);
|
||||
message.success(parts.length > 0 ? parts.join(', ') + '.' : 'Keine Änderungen.');
|
||||
if (result.updatedCount > 0 || result.idsUpdated > 0) await loadData();
|
||||
} catch {
|
||||
@@ -197,13 +203,13 @@ export default function MailpostfachPage() {
|
||||
prefix={<SearchOutlined />}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
style={{ width: 300 }}
|
||||
style={{ width: isMobile ? '100%' : 300 }}
|
||||
allowClear
|
||||
/>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={setStatusFilter}
|
||||
style={{ width: 200 }}
|
||||
style={{ width: isMobile ? '100%' : 200 }}
|
||||
options={[
|
||||
{ value: 'all', label: 'Alle Status' },
|
||||
{ value: 0, label: 'Neu' },
|
||||
@@ -213,18 +219,48 @@ export default function MailpostfachPage() {
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Table<EmailItem>
|
||||
columns={columns}
|
||||
dataSource={filteredEmails}
|
||||
loading={loading}
|
||||
rowKey="Id"
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} E-Mails` }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => navigate(`/mailpostfach/${record.Id}`),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
{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>
|
||||
columns={columns}
|
||||
dataSource={filteredEmails}
|
||||
loading={loading}
|
||||
rowKey="Id"
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} E-Mails` }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
onRow={(record) => ({
|
||||
onClick: () => navigate(`/mailpostfach/${record.Id}`),
|
||||
style: { cursor: 'pointer' },
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 dayjs from 'dayjs';
|
||||
import { posteingangApi } from '../api/posteingang';
|
||||
@@ -11,8 +11,11 @@ import type { PaperlessTag } from '../api/paperless';
|
||||
import DocumentEditModal from '../components/DocumentEditModal';
|
||||
import { getEnv } from '../utils/env';
|
||||
import { AuthImage } from '../utils/auth-resource';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import MobileCardList from '../components/MobileCardList';
|
||||
|
||||
export default function ManuellBearbeitenPage() {
|
||||
const isMobile = useIsMobile();
|
||||
const [data, setData] = useState<PosteingangDocument[]>([]);
|
||||
const [loading, setLoading] = 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 = [
|
||||
{
|
||||
title: 'Vorschau',
|
||||
@@ -97,26 +120,12 @@ export default function ManuellBearbeitenPage() {
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
width: '35%',
|
||||
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>{record.title}</div>
|
||||
{contentTags.length > 0 && (
|
||||
<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>
|
||||
);
|
||||
},
|
||||
render: (_: any, record: PosteingangDocument) => (
|
||||
<div>
|
||||
<div>{record.title}</div>
|
||||
{renderContentTags(record)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Eingangsdatum',
|
||||
@@ -153,14 +162,47 @@ export default function ManuellBearbeitenPage() {
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
/>
|
||||
{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
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentEditModal
|
||||
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 { ReloadOutlined } from '@ant-design/icons';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
import dayjs from 'dayjs';
|
||||
import { posteingangApi } from '../api/posteingang';
|
||||
import type { PosteingangDocument } from '../api/posteingang';
|
||||
import DocumentEditModal from '../components/DocumentEditModal';
|
||||
import { getEnv } from '../utils/env';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import MobileCardList from '../components/MobileCardList';
|
||||
|
||||
export default function PosteingangPage() {
|
||||
const isMobile = useIsMobile();
|
||||
const [data, setData] = useState<PosteingangDocument[]>([]);
|
||||
const [loading, setLoading] = 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 = [
|
||||
{
|
||||
title: 'Vorschau',
|
||||
@@ -88,10 +96,7 @@ export default function PosteingangPage() {
|
||||
{
|
||||
title: 'Eingangsdatum',
|
||||
key: 'eingangsdatum',
|
||||
render: (_: any, record: PosteingangDocument) => {
|
||||
const cf = record.customFields?.find((f) => f.field === 9);
|
||||
return cf?.value ? dayjs(cf.value).format('DD.MM.YYYY') : '-';
|
||||
}
|
||||
render: (_: any, record: PosteingangDocument) => getEingangsdatum(record),
|
||||
},
|
||||
{
|
||||
title: 'Importiert am',
|
||||
@@ -121,14 +126,46 @@ export default function PosteingangPage() {
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
/>
|
||||
{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
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} Dokumente` }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DocumentEditModal
|
||||
documentId={selectedDoc?.id || null}
|
||||
|
||||
@@ -2,14 +2,14 @@ import { useEffect, useState, useCallback } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Tabs, Typography, Table, Button, Modal, Form, Input, Select,
|
||||
Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, Radio, Alert,
|
||||
Switch, Checkbox, Popconfirm, message, Card, Tag, Space, Divider, InputNumber, Badge, Row, Col, Radio, Alert, Descriptions,
|
||||
} from 'antd';
|
||||
import {
|
||||
UserOutlined, FileTextOutlined, ThunderboltOutlined,
|
||||
PlusOutlined, DeleteOutlined, EditOutlined, CloudUploadOutlined,
|
||||
HistoryOutlined, MinusCircleOutlined, CopyOutlined, KeyOutlined,
|
||||
QrcodeOutlined, UnorderedListOutlined, PrinterOutlined, GlobalOutlined,
|
||||
TagsOutlined,
|
||||
TagsOutlined, ApiOutlined, ReloadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { FormInstance } from 'antd';
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../api/settings';
|
||||
import { clientsApi, type Client } from '../api/inbox';
|
||||
import { apiKeysApi, type ApiKey } from '../api/api-keys';
|
||||
import { webhookApi, type WebhookStatus } from '../api/webhook';
|
||||
import {
|
||||
barcodeTemplatesApi,
|
||||
type BarcodeTemplate,
|
||||
@@ -320,7 +321,7 @@ function UserClientsTab() {
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)} style={{ marginBottom: 16 }}>
|
||||
Zuordnung hinzufügen
|
||||
</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 />
|
||||
<Typography.Title level={5} style={{ marginBottom: 8 }}>Betriebe — Agrarmonitor-Zuordnung</Typography.Title>
|
||||
@@ -334,6 +335,7 @@ function UserClientsTab() {
|
||||
rowKey="Id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
|
||||
<Modal title="Neue Zuordnung" open={modalOpen} onOk={handleAdd} onCancel={() => setModalOpen(false)}>
|
||||
@@ -598,13 +600,14 @@ function DocTypesTab() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<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="Dokumenttyp bearbeiten"
|
||||
@@ -1038,7 +1041,7 @@ function PostprocessingTab() {
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
|
||||
Regel hinzufügen
|
||||
</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 ? 'Neue Postprocessing-Regel' : 'Regel bearbeiten'}
|
||||
@@ -1180,7 +1183,7 @@ function ExportTargetsTab() {
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openNew} style={{ marginBottom: 16 }}>
|
||||
Export-Ziel hinzufügen
|
||||
</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)}>
|
||||
<Form form={form} layout="vertical">
|
||||
@@ -1246,6 +1249,7 @@ function PostprocessingLogsTab() {
|
||||
loading={loading}
|
||||
rowKey="Id"
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: page,
|
||||
total,
|
||||
@@ -1351,7 +1355,7 @@ function ApiKeysTab() {
|
||||
</Button>
|
||||
</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
|
||||
title="Neuen API-Key erstellen"
|
||||
@@ -1571,13 +1575,14 @@ function CorrespondentsTab() {
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{
|
||||
<Table
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
scroll={{ x: 'max-content' }}
|
||||
pagination={{
|
||||
current: currentPage,
|
||||
pageSize: pageSize,
|
||||
total: total,
|
||||
@@ -1910,7 +1915,7 @@ function InboxActionsForTemplateEditor({ templateId }: { templateId: number }) {
|
||||
<h4 style={{ margin: 0 }}>Weiterverarbeitungs-Aktionen</h4>
|
||||
<Button type="dashed" size="small" icon={<PlusOutlined />} onClick={openNew}>Aktion hinzufügen</Button>
|
||||
</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
|
||||
title={isNew ? 'Neue Aktion' : 'Aktion bearbeiten'}
|
||||
@@ -2167,6 +2172,7 @@ function BarcodeTemplatesTab() {
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
@@ -2599,6 +2605,20 @@ function AgrarmonitorTab() {
|
||||
))}
|
||||
</Select>
|
||||
</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}>
|
||||
Speichern
|
||||
</Button>
|
||||
@@ -2748,6 +2768,98 @@ function SteuertagsTab() {
|
||||
// Settings Page
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// Webhook-Status Tab
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
function WebhookStatusTab() {
|
||||
const [status, setStatus] = useState<WebhookStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await webhookApi.getStatus();
|
||||
setStatus(data);
|
||||
} catch (err) {
|
||||
console.error('Error loading webhook status:', err);
|
||||
message.error('Webhook-Status konnte nicht geladen werden');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const renderStatusBadge = (s: string) => {
|
||||
switch (s) {
|
||||
case 'queued': return <Badge status="processing" text="In Warteschlange" />;
|
||||
case 'processed': return <Badge status="success" text="Verarbeitet" />;
|
||||
case 'error': return <Badge status="error" text="Fehler" />;
|
||||
case 'bad-request': return <Badge status="error" text="Ungültige Anfrage" />;
|
||||
default: return <Badge status="default" text={s} />;
|
||||
}
|
||||
};
|
||||
|
||||
const lastCall = status?.lastCall ?? null;
|
||||
const queueSize = status?.queueSize ?? 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Title level={4}>Webhook-Status</Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
Paperless-NGX ruft nach dem Bearbeiten eines Dokuments den Webhook{' '}
|
||||
<Typography.Text code>/api/webhook/paperless</Typography.Text> auf
|
||||
(per API-Key authentifiziert). Die ID wird in eine Warteschlange
|
||||
gelegt und von einem separaten Prozess nacheinander verarbeitet.
|
||||
Hier siehst du den zuletzt festgehaltenen Vorgang. Eine vollständige
|
||||
Historie steht in den Backend-Logs.
|
||||
</Typography.Paragraph>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} loading={loading} onClick={load}>
|
||||
Aktualisieren
|
||||
</Button>
|
||||
<Tag color={queueSize > 0 ? 'processing' : 'default'}>
|
||||
Warteschlange: {queueSize}
|
||||
</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small" title="Letzter Webhook-Aufruf" loading={loading}>
|
||||
{lastCall ? (
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="Zeitpunkt">
|
||||
{dayjs(lastCall.at).format('DD.MM.YYYY HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Dokument-ID">
|
||||
{lastCall.documentId ?? '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Aktion">
|
||||
{lastCall.action ? <Tag>{lastCall.action}</Tag> : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Ergebnis">
|
||||
{renderStatusBadge(lastCall.status)}
|
||||
{lastCall.reason ? (
|
||||
<Typography.Text type="secondary"> ({lastCall.reason})</Typography.Text>
|
||||
) : null}
|
||||
</Descriptions.Item>
|
||||
{lastCall.message ? (
|
||||
<Descriptions.Item label="Meldung">
|
||||
<Typography.Text type="danger">{lastCall.message}</Typography.Text>
|
||||
</Descriptions.Item>
|
||||
) : null}
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
Noch kein Webhook-Aufruf erfolgt.
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div>
|
||||
@@ -2805,6 +2917,11 @@ export default function SettingsPage() {
|
||||
label: <span><KeyOutlined /> API-Keys</span>,
|
||||
children: <ApiKeysTab />,
|
||||
},
|
||||
{
|
||||
key: 'webhook',
|
||||
label: <span><ApiOutlined /> Webhook</span>,
|
||||
children: <WebhookStatusTab />,
|
||||
},
|
||||
{
|
||||
key: 'agrarmonitor',
|
||||
label: <span><GlobalOutlined /> Agrarmonitor</span>,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
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 dayjs from 'dayjs';
|
||||
import { tasksApi } 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) {
|
||||
if (fertig === 1) return <Tag color="success">Fertig</Tag>;
|
||||
@@ -12,6 +16,7 @@ function statusTag(fertig: number | null) {
|
||||
}
|
||||
|
||||
export default function TaskLogPage() {
|
||||
const isMobile = useIsMobile();
|
||||
const [data, setData] = useState<Task[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
@@ -155,14 +160,55 @@ export default function TaskLogPage() {
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="TaskId"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
/>
|
||||
{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
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
rowKey="TaskId"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 20 }}
|
||||
locale={{ emptyText: 'Keine Einträge vorhanden' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ConfigProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Typography, Tag, Button, Modal, Select, message, Space, Radio, Tooltip } from 'antd';
|
||||
import { EuroOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { zahlungApi, type ZahlungDocument, type ZahlungOption, type ZahlungFilter } from '../api/zahlung';
|
||||
import { paperlessApi, type PaperlessDocType, type PaperlessCorrespondent } from '../api/paperless';
|
||||
import { useIsMobile } from '../hooks/useIsMobile';
|
||||
import MobileCardList from '../components/MobileCardList';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const FREIGABE_FIELD_ID = 15;
|
||||
const ZAHLUNG_FIELD_ID = 16;
|
||||
const FREIGABE_WERT_FREIGEGEBEN = 'freigegeben';
|
||||
|
||||
const FILTER_OPTIONS: { value: ZahlungFilter; label: string }[] = [
|
||||
{ value: 'ausstehend', label: 'Freigegeben, noch nicht bezahlt' },
|
||||
{ value: 'freigegeben', label: 'Alle freigegebenen' },
|
||||
{ value: 'alle', label: 'Alle' },
|
||||
];
|
||||
|
||||
export default function ZahlungPage() {
|
||||
const isMobile = useIsMobile();
|
||||
const [data, setData] = useState<ZahlungDocument[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [filter, setFilter] = useState<ZahlungFilter>('ausstehend');
|
||||
|
||||
const [docTypes, setDocTypes] = useState<PaperlessDocType[]>([]);
|
||||
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
|
||||
const [zahlungOptions, setZahlungOptions] = useState<ZahlungOption[]>([]);
|
||||
|
||||
const [selectedDoc, setSelectedDoc] = useState<ZahlungDocument | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedValue, setSelectedValue] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
paperlessApi.getDocumentTypes(),
|
||||
paperlessApi.getCorrespondents(),
|
||||
zahlungApi.getOptions(),
|
||||
]).then(([dts, corrs, opts]) => {
|
||||
setDocTypes(dts);
|
||||
setCorrespondents(corrs);
|
||||
setZahlungOptions(opts);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await zahlungApi.getDocuments(page, pageSize, filter);
|
||||
setData(result.results ?? []);
|
||||
setTotal(result.count ?? 0);
|
||||
} catch {
|
||||
message.error('Fehler beim Laden der Belege');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, pageSize, filter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const getDocTypeName = (id: number | null) => {
|
||||
if (!id) return '—';
|
||||
return docTypes.find((d) => d.id === id)?.name ?? String(id);
|
||||
};
|
||||
|
||||
const getCorrespondentName = (id: number | null) => {
|
||||
if (!id) return '—';
|
||||
return correspondents.find((c) => c.id === id)?.name ?? String(id);
|
||||
};
|
||||
|
||||
const toCfString = (value: any): string | null => {
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
if (typeof value === 'object') return String(value?.id ?? value?.value ?? value?.label ?? '') || null;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const getCfValue = (doc: ZahlungDocument, fieldId: number) => {
|
||||
const cf = doc.custom_fields?.find((f) => f.field === fieldId);
|
||||
return toCfString(cf?.value);
|
||||
};
|
||||
|
||||
const renderFreigabeTag = (doc: ZahlungDocument) => {
|
||||
const val = getCfValue(doc, FREIGABE_FIELD_ID);
|
||||
if (!val) return <Tag color="default">Nicht gesetzt</Tag>;
|
||||
if (val === FREIGABE_WERT_FREIGEGEBEN) return <Tag color="success">Freigegeben</Tag>;
|
||||
return <Tag color="warning">{val}</Tag>;
|
||||
};
|
||||
|
||||
const renderZahlungTag = (doc: ZahlungDocument) => {
|
||||
const val = getCfValue(doc, ZAHLUNG_FIELD_ID);
|
||||
if (!val) return <Tag color="default">Nicht gesetzt</Tag>;
|
||||
const opt = zahlungOptions.find((o) => o.id === val);
|
||||
return <Tag color="blue">{opt?.label ?? val}</Tag>;
|
||||
};
|
||||
|
||||
const istFreigegeben = (doc: ZahlungDocument) =>
|
||||
getCfValue(doc, FREIGABE_FIELD_ID) === FREIGABE_WERT_FREIGEGEBEN;
|
||||
|
||||
const openModal = (doc: ZahlungDocument) => {
|
||||
setSelectedDoc(doc);
|
||||
setSelectedValue(getCfValue(doc, ZAHLUNG_FIELD_ID));
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleZahlung = async () => {
|
||||
if (!selectedDoc) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await zahlungApi.setZahlung(selectedDoc.id, selectedValue);
|
||||
message.success('Zahlung gesetzt');
|
||||
setModalOpen(false);
|
||||
setSelectedDoc(null);
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('Fehler beim Speichern der Zahlung');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<ZahlungDocument> = [
|
||||
{
|
||||
title: 'Dokumenttyp',
|
||||
dataIndex: 'document_type',
|
||||
key: 'doctype',
|
||||
render: getDocTypeName,
|
||||
},
|
||||
{
|
||||
title: 'Titel',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Erstellt',
|
||||
dataIndex: 'created',
|
||||
key: 'created',
|
||||
width: 110,
|
||||
render: (v: string) => v ? dayjs(v).format('DD.MM.YYYY') : '—',
|
||||
},
|
||||
{
|
||||
title: 'Absender',
|
||||
dataIndex: 'correspondent',
|
||||
key: 'correspondent',
|
||||
render: getCorrespondentName,
|
||||
},
|
||||
{
|
||||
title: 'Freigabe',
|
||||
key: 'freigabe',
|
||||
width: 130,
|
||||
render: (_, doc) => renderFreigabeTag(doc),
|
||||
},
|
||||
{
|
||||
title: 'Zahlung',
|
||||
key: 'zahlung',
|
||||
width: 130,
|
||||
render: (_, doc) => renderZahlungTag(doc),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'action',
|
||||
width: 150,
|
||||
render: (_, doc) => {
|
||||
const freigegeben = istFreigegeben(doc);
|
||||
return (
|
||||
<Tooltip title={!freigegeben ? 'Beleg muss zuerst freigegeben werden' : undefined}>
|
||||
<Button
|
||||
icon={<EuroOutlined />}
|
||||
size="small"
|
||||
type="primary"
|
||||
disabled={!freigegeben}
|
||||
onClick={() => openModal(doc)}
|
||||
>
|
||||
Zahlung verbuchen
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const paginationConfig = {
|
||||
current: page,
|
||||
pageSize,
|
||||
total,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['25', '50', '100'],
|
||||
onChange: (p: number, ps: number) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
showTotal: (t: number) => `${t} Belege`,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={4} style={{ marginTop: 0, marginBottom: 16 }}>Zahlung</Title>
|
||||
|
||||
{isMobile ? (
|
||||
<Select
|
||||
style={{ width: '100%', marginBottom: 16 }}
|
||||
value={filter}
|
||||
onChange={(v) => {
|
||||
setPage(1);
|
||||
setFilter(v);
|
||||
}}
|
||||
options={FILTER_OPTIONS}
|
||||
/>
|
||||
) : (
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Radio.Group
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setPage(1);
|
||||
setFilter(e.target.value);
|
||||
}}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
{FILTER_OPTIONS.map((o) => (
|
||||
<Radio.Button key={o.value} value={o.value}>{o.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<MobileCardList<ZahlungDocument>
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={paginationConfig}
|
||||
emptyText="Keine Belege vorhanden"
|
||||
renderCard={(doc) => {
|
||||
const freigegeben = istFreigegeben(doc);
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
<Text strong>{doc.title || '—'}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Dokumenttyp: {getDocTypeName(doc.document_type)}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>Absender: {getCorrespondentName(doc.correspondent)}</Text>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
Erstellt: {doc.created ? dayjs(doc.created).format('DD.MM.YYYY') : '—'}
|
||||
</Text>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{renderFreigabeTag(doc)}
|
||||
{renderZahlungTag(doc)}
|
||||
</div>
|
||||
<Button
|
||||
icon={<EuroOutlined />}
|
||||
type="primary"
|
||||
block
|
||||
disabled={!freigegeben}
|
||||
onClick={() => openModal(doc)}
|
||||
>
|
||||
Zahlung verbuchen
|
||||
</Button>
|
||||
{!freigegeben && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Beleg muss zuerst freigegeben werden
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Table<ZahlungDocument>
|
||||
dataSource={data}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
size="small"
|
||||
pagination={paginationConfig}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Zahlung verbuchen"
|
||||
open={modalOpen}
|
||||
onOk={handleZahlung}
|
||||
onCancel={() => { setModalOpen(false); setSelectedDoc(null); }}
|
||||
okText="Speichern"
|
||||
cancelText="Abbrechen"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<p style={{ marginBottom: 12 }}>
|
||||
<strong>{selectedDoc?.title}</strong>
|
||||
</p>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Zahlungsstatus wählen"
|
||||
allowClear
|
||||
value={selectedValue ?? undefined}
|
||||
onChange={(v) => setSelectedValue(v ?? null)}
|
||||
options={zahlungOptions.map((o) => ({ value: o.id, label: o.label }))}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user