Initial commit with Email Import Wizard and Task Processor updates

This commit is contained in:
2026-05-04 08:02:11 +02:00
commit effdc5d59f
170 changed files with 67739 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
.env
.git
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+23
View File
@@ -0,0 +1,23 @@
# Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_API_URL
ARG VITE_OIDC_AUTHORITY
ARG VITE_OIDC_CLIENT_ID
RUN npm run build
# Serve via nginx
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Runtime env injection script
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
EXPOSE 80
ENTRYPOINT ["/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Runtime environment variable injection for Vite SPA
# Replaces build-time VITE_* placeholders with runtime env values
ENV_JS="/usr/share/nginx/html/env-config.js"
cat <<EOF > "$ENV_JS"
window.__ENV__ = {
VITE_API_URL: "${VITE_API_URL:-http://localhost:3100}",
VITE_OIDC_AUTHORITY: "${VITE_OIDC_AUTHORITY:-}",
VITE_OIDC_CLIENT_ID: "${VITE_OIDC_CLIENT_ID:-}",
VITE_OIDC_REDIRECT_URI: "${VITE_OIDC_REDIRECT_URI:-}",
};
EOF
exec "$@"
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>Paperless Manager</title>
</head>
<body>
<div id="root"></div>
<script src="/env-config.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ── API Reverse Proxy → Backend-Container ──────────────────
# Im Docker-Netzwerk ist "backend" der Service-Name aus docker-compose.
location /api/ {
proxy_pass http://backend:3100/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
client_max_body_size 50M;
}
# Webhook ebenfalls zum Backend durchreichen
location /webhook/ {
proxy_pass http://backend:3100/webhook/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# ── SPA Fallback ──────────────────────────────────────────
location / {
try_files $uri $uri/ /index.html;
}
# Caching für statische Assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Kein Caching für index.html und env-config.js
location = /index.html {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /env-config.js {
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
}
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "paperless-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"antd": "^6.3.4",
"axios": "^1.14.0",
"dayjs": "^1.11.20",
"oidc-client-ts": "^3.5.0",
"pdfjs-dist": "^5.7.284",
"qrcode": "^1.5.4",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2",
"spark-md5": "^3.0.2"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/spark-md5": "^3.0.5",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}
+3
View File
@@ -0,0 +1,3 @@
// Dummy-Datei für die lokale Entwicklung
// In Produktion wird diese Datei durch ein Skript im Docker-Container generiert.
window.__ENV__ = {};
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+145
View File
@@ -0,0 +1,145 @@
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { ConfigProvider, theme, App as AntdApp } from 'antd';
import deDE from 'antd/locale/de_DE';
import { AuthProvider, useAuth } from './auth/AuthContext';
import { saveReturnUrl } from './auth/sessionRedirect';
import { ThemeProvider, useTheme } from './theme/ThemeContext';
import AuthCallback from './auth/AuthCallback';
import AppLayout from './layouts/AppLayout';
import InboxPage from './pages/InboxPage';
import InboxDetailPage from './pages/InboxDetailPage';
import PosteingangPage from './pages/PosteingangPage';
import ManuellBearbeitenPage from './pages/ManuellBearbeitenPage';
import MailpostfachPage from './pages/MailpostfachPage';
import MailDetailPage from './pages/MailDetailPage';
import SettingsPage from './pages/SettingsPage';
import LoginPage from './pages/LoginPage';
import DashboardPage from './pages/DashboardPage';
import { Spin, Result, Button } from 'antd';
import type { ReactNode } from 'react';
import { Permission } from './auth/permissions';
function UnauthorizedPage() {
const navigate = useNavigate();
return (
<Result
status="403"
title="403"
subTitle="Entschuldigung, Sie haben keine Berechtigung, auf diese Seite zuzugreifen."
extra={<Button type="primary" onClick={() => navigate('/')}>Zurück zur Startseite</Button>}
/>
);
}
function ProtectedRoute({ children }: { children: ReactNode }) {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!isAuthenticated) {
saveReturnUrl(location.pathname + location.search);
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function PermissionRoute({ permission, children }: { permission: Permission; children: ReactNode }) {
const { hasPermission } = useAuth();
if (!hasPermission(permission)) {
return <UnauthorizedPage />;
}
return <>{children}</>;
}
function ThemedApp() {
const { isDark } = useTheme();
return (
<ConfigProvider
locale={deDE}
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: '#1677ff',
borderRadius: 6,
...(isDark
? {}
: {
// Extra-helles Light Theme — minimale Spiegelungsbelastung
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#f8f9fc',
colorBgBase: '#ffffff',
colorText: '#1a1a2e',
colorTextSecondary: '#4a4a6a',
colorBorder: '#e2e4ea',
colorBorderSecondary: '#ebedf2',
}),
},
components: isDark
? {}
: {
Layout: {
siderBg: '#f0f2f7',
headerBg: '#ffffff',
bodyBg: '#f8f9fc',
triggerBg: '#e2e4ea',
},
Menu: {
itemBg: 'transparent',
itemColor: '#4a4a6a',
itemSelectedBg: '#e6f0ff',
itemSelectedColor: '#1677ff',
itemHoverBg: '#eef1f8',
itemHoverColor: '#1a1a2e',
},
},
}}
>
<AuthProvider>
<AntdApp>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route index element={<DashboardPage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/inbox" element={<PermissionRoute permission={Permission.VIEW_SCANNER}><InboxPage /></PermissionRoute>} />
<Route path="/inbox/:id" element={<PermissionRoute permission={Permission.VIEW_SCANNER}><InboxDetailPage /></PermissionRoute>} />
<Route path="/posteingang" element={<PermissionRoute permission={Permission.VIEW_INBOX}><PosteingangPage /></PermissionRoute>} />
<Route path="/manuell" element={<PermissionRoute permission={Permission.PROCESS_MANUALLY}><ManuellBearbeitenPage /></PermissionRoute>} />
<Route path="/mailpostfach" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailpostfachPage /></PermissionRoute>} />
<Route path="/mailpostfach/:id" element={<PermissionRoute permission={Permission.VIEW_MAIL}><MailDetailPage /></PermissionRoute>} />
<Route path="/settings" element={<PermissionRoute permission={Permission.MANAGE_SETTINGS}><SettingsPage /></PermissionRoute>} />
</Route>
</Routes>
</BrowserRouter>
</AntdApp>
</AuthProvider>
</ConfigProvider>
);
}
export default function App() {
return (
<ThemeProvider>
<ThemedApp />
</ThemeProvider>
);
}
+22
View File
@@ -0,0 +1,22 @@
import api from './client';
export interface ApiKey {
id: string;
name: string;
keyPrefix: string;
createdAt: string;
lastUsedAt: string | null;
expiresAt: string | null;
}
export interface CreatedApiKey {
plainKey: string;
entity: ApiKey;
}
export const apiKeysApi = {
getApiKeys: () => api.get<ApiKey[]>('/api/api-keys').then(r => r.data),
createApiKey: (name: string, expiresDays?: number) =>
api.post<CreatedApiKey>('/api/api-keys', { name, expiresDays }).then(r => r.data),
deleteApiKey: (id: string) => api.delete(`/api/api-keys/${id}`).then(r => r.data),
};
@@ -0,0 +1,45 @@
import api from './client';
export type BarcodeActionType = 'SEND_TO_PAPERLESS' | 'SEND_BY_EMAIL';
export interface BarcodeTemplate {
Id: number;
Name: string;
Regex: string;
SplitBefore: boolean;
DateinameTemplate: string | null;
Actions: BarcodeActionType[];
CreatedAt: string;
UpdatedAt: string;
}
export interface BarcodeTemplateInput {
Name: string;
Regex: string;
SplitBefore: boolean;
DateinameTemplate?: string | null;
Actions: BarcodeActionType[];
}
export const BARCODE_ACTION_LABELS: Record<BarcodeActionType, string> = {
SEND_TO_PAPERLESS: 'Datei an Paperless senden',
SEND_BY_EMAIL: 'Datei per E-Mail senden',
};
export const barcodeTemplatesApi = {
list: () =>
api.get<BarcodeTemplate[]>('/api/barcode-templates').then((r) => r.data),
create: (input: BarcodeTemplateInput) =>
api
.post<BarcodeTemplate>('/api/barcode-templates', input)
.then((r) => r.data),
update: (id: number, input: Partial<BarcodeTemplateInput>) =>
api
.put<BarcodeTemplate>(`/api/barcode-templates/${id}`, input)
.then((r) => r.data),
remove: (id: number) =>
api.delete(`/api/barcode-templates/${id}`).then((r) => r.data),
};
+29
View File
@@ -0,0 +1,29 @@
import axios from 'axios';
import { getAccessToken } from '../auth/oidc';
import { triggerLoginRedirect } from '../auth/sessionRedirect';
import { getEnv } from '../utils/env';
const api = axios.create({
baseURL: getEnv('VITE_API_URL') || '',
timeout: 30000,
});
api.interceptors.request.use(async (config) => {
const token = await getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
await triggerLoginRedirect();
}
return Promise.reject(error);
},
);
export default api;
@@ -0,0 +1,68 @@
import api from './client';
export interface CorrespondentMapping {
Id: number;
EmailAddress: string;
PaperlessCorrespondentId: number;
}
export interface AttachmentImportData {
attachmentId: number;
virtualId: string;
fileName: string;
pages?: { start: number; end: number };
type: 'MAIN' | 'ATTACHMENT' | 'IGNORE';
paperlessCorrespondentId?: number | null;
parentDocumentId?: number | null;
parentVirtualId?: string | null;
splitRanges?: { start: number; end: number }[];
barcode?: { x: number; y: number; nummer: string; datum: string; jahr: string };
belegnummer?: string;
isDuplicate?: boolean;
}
export const emailImportApi = {
getMappings: async (): Promise<CorrespondentMapping[]> => {
const res = await api.get('/api/email-import/mappings');
return res.data;
},
addMapping: async (emailAddress: string, paperlessCorrespondentId: number): Promise<CorrespondentMapping> => {
const res = await api.post('/api/email-import/mappings', { emailAddress, paperlessCorrespondentId });
return res.data;
},
deleteMapping: async (id: number): Promise<void> => {
await api.delete(`/api/email-import/mappings/${id}`);
},
getCorrespondentByEmail: async (emailAddress: string): Promise<{ paperlessCorrespondentId: number | null }> => {
const res = await api.get('/api/email-import/correspondent', { params: { email: emailAddress } });
return res.data;
},
getBelegnummer: async (dateStr: string): Promise<{ nummer: string }> => {
const res = await api.get('/api/email-import/belegnummer', { params: { date: dateStr } });
return res.data;
},
releaseBelegnummer: async (dateStr: string, number: string): Promise<void> => {
await api.post('/api/email-import/belegnummer/release', { date: dateStr, number });
},
printPreview: async (attachmentId: number, barcodeData: any): Promise<Blob> => {
const res = await api.post(`/api/email-import/attachments/${attachmentId}/print-preview`, barcodeData, {
responseType: 'blob',
});
return res.data;
},
executeImport: async (emailDate: string, attachments: AttachmentImportData[]): Promise<{ success: boolean; results: any[] }> => {
const res = await api.post('/api/email-import/execute', { emailDate, attachments });
return res.data;
},
ensurePreviews: async (emailId: number): Promise<void> => {
await api.post(`/api/email-import/emails/${emailId}/ensure-previews`);
},
};
+51
View File
@@ -0,0 +1,51 @@
import api from './client';
export interface EmailAttachment {
Id: number;
FileName: string;
ContentType: string;
Erechnung: boolean;
Checksum?: string;
ContentId?: string | null;
IsEmbedded?: boolean;
ParentId?: number | null;
PageCount?: number;
PaperlessDocumentIds?: Record<string, number> | null;
}
export interface EmailItem {
Id: number;
MessageId: string;
SenderAddress: string;
RecipientAddress: string;
Subject: string;
Date: string;
Body: string;
Status: number;
Attachments?: EmailAttachment[];
}
export const emailsApi = {
list: (params?: { status?: number; limit?: number }) =>
api.get<EmailItem[]>('/api/emails', { params }).then((r) => r.data),
get: (id: number) =>
api.get<EmailItem>(`/api/emails/${id}`).then((r) => r.data),
listAttachments: (emailId: number) =>
api.get<EmailAttachment[]>(`/api/emails/${emailId}/attachments`).then((r) => r.data),
getAttachmentContent: (attachmentId: number) =>
api.get<Blob>(`/api/emails/attachments/${attachmentId}/content`, {
responseType: 'blob',
}).then((r) => r.data),
triggerFetch: () =>
api.post<{ message: string }>('/api/emails/fetch').then((r) => r.data),
checkAttachments: () =>
api.post<{ updatedCount: number }>('/api/emails/check-attachments').then((r) => r.data),
updateStatus: (id: number, status: number) =>
api.patch<{ message?: string }>(`/api/emails/${id}/status`, { status }).then((r) => r.data),
};
+106
View File
@@ -0,0 +1,106 @@
import api from './client';
import type { BarcodeActionType } from './barcode-templates';
export type InboxSource = 'all' | 'user';
export interface InboxBarcode {
page: number;
value: string;
templateId: number | null;
templateName: string | null;
splitBefore: boolean;
actions: BarcodeActionType[];
}
export interface InboxFile {
id: string;
name: string;
source: InboxSource;
pageCount: number;
deletedPages: number[];
rotations: Record<string, number>;
barcodes: InboxBarcode[];
createdAt: string;
}
export interface Client {
Id: number;
Name: string;
PaperlessUserId: number;
}
export const inboxApi = {
list: () => api.get<InboxFile[]>('/api/inbox').then((r) => r.data),
rescan: () =>
api
.post<{ scanned: number; failed: number }>('/api/inbox/rescan', {}, { timeout: 600000 })
.then((r) => r.data),
previewBlob: (id: string) =>
api
.get<Blob>(`/api/inbox/${encodeURIComponent(id)}/preview`, { responseType: 'blob' })
.then((r) => r.data),
thumbnailBlob: (id: string, page: number) =>
api
.get<Blob>(
`/api/inbox/${encodeURIComponent(id)}/pages/${page}/thumbnail`,
{ responseType: 'blob' },
)
.then((r) => r.data),
pagePreviewBlob: (id: string, page: number) =>
api
.get<Blob>(
`/api/inbox/${encodeURIComponent(id)}/pages/${page}/preview`,
{ responseType: 'blob' },
)
.then((r) => r.data),
remove: (id: string) =>
api.delete(`/api/inbox/${encodeURIComponent(id)}`).then((r) => r.data),
removePage: (id: string, page: number) =>
api
.delete(`/api/inbox/${encodeURIComponent(id)}/pages/${page}`)
.then((r) => r.data),
resetEdits: (id: string) =>
api
.post(`/api/inbox/${encodeURIComponent(id)}/reset-edits`)
.then((r) => r.data),
setPageRotation: (id: string, page: number, rotation: number) =>
api
.put(
`/api/inbox/${encodeURIComponent(id)}/pages/${page}/rotation`,
{ rotation },
)
.then((r) => r.data),
postprocess: (
id: string,
opts?: { sectionOffset?: number; processOnlyOne?: boolean; replaceDuplicate?: boolean },
) =>
api
.post<{ results: PostprocessActionResult[]; totalSections: number }>(
`/api/inbox/${encodeURIComponent(id)}/postprocess`,
opts ?? {},
)
.then((r) => r.data),
};
export interface PostprocessActionResult {
sectionIndex: number;
actionId: number;
actionType: string;
ok: boolean;
skipped?: boolean;
message?: string;
duplicateOfDocumentId?: number;
}
export const clientsApi = {
getMyClients: () => api.get<Client[]>('/api/clients').then((r) => r.data),
};
+64
View File
@@ -0,0 +1,64 @@
import api from './client';
export interface PaperlessTag {
id: number;
slug: string;
name: string;
color: string;
text_color: string;
match: string;
matching_algorithm: number;
is_insensitive: boolean;
is_inbox_tag: boolean;
document_count: number;
}
export interface PaperlessDocType {
id: number;
slug: string;
name: string;
match: string;
matching_algorithm: number;
is_insensitive: boolean;
document_count: number;
}
export interface PaperlessCustomField {
id: number;
name: string;
data_type: string;
extra_data?: any;
}
export interface PaperlessCorrespondent {
id: number;
slug: string;
name: string;
match: string;
matching_algorithm: number;
is_insensitive: boolean;
document_count: number;
}
export interface PaperlessUser {
id: number;
username: string;
first_name?: string;
last_name?: string;
}
export const paperlessApi = {
getTags: () => api.get<PaperlessTag[]>('/api/paperless/tags').then(r => r.data),
getDocumentTypes: () => api.get<PaperlessDocType[]>('/api/paperless/document-types').then(r => r.data),
getCustomFields: () => api.get<PaperlessCustomField[]>('/api/paperless/custom-fields').then(r => r.data),
getCorrespondents: (search?: string) => api.get<PaperlessCorrespondent[]>('/api/paperless/correspondents', { params: { search } }).then(r => r.data),
getCorrespondent: (id: number) => api.get<PaperlessCorrespondent>(`/api/paperless/correspondents/${id}`).then(r => r.data),
getUsers: () => api.get<PaperlessUser[]>('/api/paperless/users').then(r => r.data),
searchDocuments: (params: { search?: string, page?: number, pageSize?: number }) =>
api.get<{ results: any[], count: number }>('/api/paperless/documents', { params }).then(r => r.data),
getDocument: (id: number) => api.get<any>(`/api/paperless/documents/${id}`).then(r => r.data),
checksumExists: (checksum: string) =>
api.post<{ exists: boolean }>('/api/paperless/checksum', { checksum }).then(r => r.data.exists),
getDocumentPdfBlob: (id: number) =>
api.get<Blob>(`/api/paperless/inbox/pdf/${id}`, { responseType: 'blob' }).then(r => r.data),
};
+57
View File
@@ -0,0 +1,57 @@
import api from './client';
export interface PosteingangDocument {
id: number;
title: string;
asn?: number;
documentType?: number;
correspondent?: number;
created?: string;
added: string;
tags: number[];
customFields: { field: number; value: any }[];
owner?: number;
}
export interface DocumentRequirement {
id: number;
feldId: string;
feldName: string;
feldTyp: string;
hinweis: string;
required: boolean;
isCustomField?: boolean;
customFieldIndex?: number;
fieldOptions?: { id: string | number; label: string }[];
}
export interface Kontonummer {
KontonummerId?: number;
CorrespondentId: number;
Nummer: string;
}
export const posteingangApi = {
getList: async (): Promise<PosteingangDocument[]> => {
const res = await api.get('/api/paperless/inbox/list');
return res.data;
},
getManuellList: async (): Promise<PosteingangDocument[]> => {
const res = await api.get('/api/paperless/manuell/list');
return res.data;
},
getRequirements: (docTypeId: number, isPosteingang: boolean = true) =>
api.get<DocumentRequirement[]>(`/api/paperless/requirements/${docTypeId}?Posteingang=${isPosteingang ? '1' : '0'}`)
.then((r) => r.data),
updateDocument: (id: number, data: any) =>
api.put(`/api/paperless/inbox/${id}`, data).then((r) => r.data),
getKontonummern: (correspondentId: number) =>
api.get<Kontonummer[]>(`/api/kontonummern/FromCorrespondent/${correspondentId}`).then(r => r.data),
createKontonummer: (data: { correspondentId: number; nummer: string }) =>
api.post<Kontonummer>('/api/kontonummern', data).then(r => r.data),
};
+178
View File
@@ -0,0 +1,178 @@
import api from './client';
export interface SettingDocType {
Id: number;
DocumentTypeId: number;
TitelTemplate: string;
TagNotReady: number | null;
TagReady: number | null;
}
export interface SettingDocField {
Id: number;
DocumentType: number;
Type: number;
TypeIndex: number | null;
IsRequired: boolean;
IsRequiredPosteingang: boolean;
Hinweis: string | null;
VisiblePosteingang: boolean;
}
// Filter types
export interface FilterCondition {
field: string;
operator: string;
value: any;
}
export interface FilterGroup {
combinator: 'AND' | 'OR';
rules: (FilterCondition | FilterGroup)[];
}
export interface SettingPostprocessing {
Id: number;
Name: string;
FilterJson: FilterGroup;
Order: number;
IsActive: boolean;
NoFurther: boolean;
}
export interface SettingPostprocessingAction {
Id: number;
PostprocessingId: number;
ActionType: number; // 1=Export, 2=Mail, 3=Tags, 4=CustomField, 5=Webhook
Content: Record<string, any>;
Order: number;
IsActive: boolean;
}
export interface SettingExportTarget {
Id: number;
Name: string;
Protocol: string; // 'ftp' | 'webdav'
Host: string;
Port: number | null;
Username: string | null;
Password: string | null;
RemotePath: string | null;
IsActive: boolean;
}
export interface SettingPostprocessingLog {
Id: number;
PostprocessingId: number;
ActionId: number | null;
DocumentId: number;
Status: string;
Message: string | null;
CreatedAt: string;
}
export interface SettingUserClient {
Id: number;
UserId: string;
ClientId: number;
Role: 'viewer' | 'editor' | 'admin';
}
export const settingsApi = {
// Dokumenttypen
getDocTypes: () => api.get<SettingDocType[]>('/api/settings/document-types').then(r => r.data),
updateDocType: (id: number, data: Partial<SettingDocType>) =>
api.put<SettingDocType>(`/api/settings/document-types/${id}`, data).then(r => r.data),
// Document Fields
getDocFields: (docTypeId: number) =>
api.get<SettingDocField[]>(`/api/settings/document-types/${docTypeId}/fields`).then(r => r.data),
createDocField: (docTypeId: number, data: Partial<SettingDocField>) =>
api.post<SettingDocField>(`/api/settings/document-types/${docTypeId}/fields`, data).then(r => r.data),
updateDocField: (id: number, data: Partial<SettingDocField>) =>
api.put<SettingDocField>(`/api/settings/document-fields/${id}`, data).then(r => r.data),
deleteDocField: (id: number) =>
api.delete(`/api/settings/document-fields/${id}`).then(r => r.data),
// Postprocessing
getPostprocessing: () => api.get<SettingPostprocessing[]>('/api/settings/postprocessing').then(r => r.data),
createPostprocessing: (data: Partial<SettingPostprocessing>) =>
api.post<SettingPostprocessing>('/api/settings/postprocessing', data).then(r => r.data),
updatePostprocessing: (id: number, data: Partial<SettingPostprocessing>) =>
api.put<SettingPostprocessing>(`/api/settings/postprocessing/${id}`, data).then(r => r.data),
deletePostprocessing: (id: number) =>
api.delete(`/api/settings/postprocessing/${id}`).then(r => r.data),
duplicatePostprocessing: (id: number) =>
api.post<SettingPostprocessing>(`/api/settings/postprocessing/${id}/duplicate`).then(r => r.data),
// Postprocessing Actions
getActions: (ppId: number) =>
api.get<SettingPostprocessingAction[]>(`/api/settings/postprocessing/${ppId}/actions`).then(r => r.data),
createAction: (ppId: number, data: Partial<SettingPostprocessingAction>) =>
api.post<SettingPostprocessingAction>(`/api/settings/postprocessing/${ppId}/actions`, data).then(r => r.data),
updateAction: (actionId: number, data: Partial<SettingPostprocessingAction>) =>
api.put<SettingPostprocessingAction>(`/api/settings/postprocessing-actions/${actionId}`, data).then(r => r.data),
deleteAction: (actionId: number) =>
api.delete(`/api/settings/postprocessing-actions/${actionId}`).then(r => r.data),
// Export-Ziele
getExportTargets: () => api.get<SettingExportTarget[]>('/api/settings/export-targets').then(r => r.data),
createExportTarget: (data: Partial<SettingExportTarget>) =>
api.post<SettingExportTarget>('/api/settings/export-targets', data).then(r => r.data),
updateExportTarget: (id: number, data: Partial<SettingExportTarget>) =>
api.put<SettingExportTarget>(`/api/settings/export-targets/${id}`, data).then(r => r.data),
deleteExportTarget: (id: number) =>
api.delete(`/api/settings/export-targets/${id}`).then(r => r.data),
testExportTarget: (id: number) =>
api.post<{ success: boolean; message: string }>(`/api/settings/export-targets/${id}/test`).then(r => r.data),
// Postprocessing Logs
getPostprocessingLogs: (limit = 50, offset = 0) =>
api.get<{ data: SettingPostprocessingLog[]; total: number }>('/api/settings/postprocessing-logs', { params: { limit, offset } }).then(r => r.data),
// Benutzer-Betrieb
getUserClients: () => api.get<SettingUserClient[]>('/api/settings/user-clients').then(r => r.data),
createUserClient: (data: Partial<SettingUserClient>) =>
api.post<SettingUserClient>('/api/settings/user-clients', data).then(r => r.data),
deleteUserClient: (id: number) =>
api.delete(`/api/settings/user-clients/${id}`).then(r => r.data),
// Korrespondenten
getCorrespondents: (page = 1, pageSize = 50, search?: string) =>
api.get<{ data: any[], total: number }>('/api/settings/correspondents', { params: { page, pageSize, search } }).then(r => r.data),
createCorrespondent: (data: { name: string }) => api.post<any>('/api/settings/correspondents', data).then(r => r.data),
updateCorrespondentSetting: (id: number, agrarmonitorId: number | null) =>
api.put<any>(`/api/settings/correspondents/${id}`, { agrarmonitorId }).then(r => r.data),
// Inbox-Postprozessor (global, deprecated)
listInboxActions: () =>
api.get<InboxAction[]>('/api/settings/inbox-actions').then((r) => r.data),
createInboxAction: (data: Partial<InboxAction>) =>
api.post<InboxAction>('/api/settings/inbox-actions', data).then((r) => r.data),
updateInboxAction: (id: number, data: Partial<InboxAction>) =>
api.put<InboxAction>(`/api/settings/inbox-actions/${id}`, data).then((r) => r.data),
deleteInboxAction: (id: number) =>
api.delete(`/api/settings/inbox-actions/${id}`).then((r) => r.data),
// Inbox-Aktionen pro Barcode-Vorlage
listInboxActionsForTemplate: (templateId: number) =>
api.get<InboxAction[]>(`/api/settings/barcode-templates/${templateId}/inbox-actions`).then((r) => r.data),
createInboxActionForTemplate: (templateId: number, data: Partial<InboxAction>) =>
api.post<InboxAction>(`/api/settings/barcode-templates/${templateId}/inbox-actions`, data).then((r) => r.data),
};
export type InboxActionType = 'MAIL' | 'EXPORT' | 'PAPERLESS';
export interface InboxAction {
Id: number;
ActionType: InboxActionType;
Content: Record<string, any>;
Order: number;
IsActive: boolean;
}
export const INBOX_ACTION_LABELS: Record<InboxActionType, string> = {
MAIL: 'Per E-Mail senden',
EXPORT: 'Export (FTP/WebDAV)',
PAPERLESS: 'In Paperless importieren',
};
+13
View File
@@ -0,0 +1,13 @@
import api from './client';
export interface StatsCounts {
inbox: number;
posteingang: number;
manuell: number;
mailpostfach: number;
}
export const statsApi = {
getCounts: () =>
api.get<StatsCounts>('/api/stats/counts').then((r) => r.data),
};
+29
View File
@@ -0,0 +1,29 @@
import api from './client';
export interface Task {
TaskId: string;
InterneBelegnummer: string;
DocumentType: number | null;
Eingangsdatum: string | null;
Fertig: number | null;
Lieferant: string | null;
externeBelegnummer: string | null;
Belegdatum: string | null;
PaperlessDocumentID: number | null;
TaskReferenceID: string | null;
}
export const tasksApi = {
getAll: async (): Promise<Task[]> => {
const res = await api.get('/api/paperless/tasks');
return res.data;
},
deleteFertige: async (): Promise<{ deleted: number }> => {
const res = await api.delete('/api/paperless/tasks/fertig');
return res.data;
},
deleteOne: async (taskId: string): Promise<{ deleted: number }> => {
const res = await api.delete(`/api/paperless/tasks/${encodeURIComponent(taskId)}`);
return res.data;
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,36 @@
import { useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { handleCallback, userManager } from './oidc';
import { consumeReturnUrl } from './sessionRedirect';
import { Spin } from 'antd';
export default function AuthCallback() {
const navigate = useNavigate();
const processed = useRef(false);
useEffect(() => {
if (processed.current) return;
processed.current = true;
// Behandelt Silent Renew im Iframe
if (window !== window.parent || window.opener) {
userManager.signinSilentCallback().catch(err => {
console.error('Silent renew callback error:', err);
});
return;
}
handleCallback()
.then(() => navigate(consumeReturnUrl(), { replace: true }))
.catch((err) => {
console.error('Auth Callback Fehler:', err);
navigate('/login', { replace: true });
});
}, [navigate]);
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" description="Anmeldung wird verarbeitet..." />
</div>
);
}
@@ -0,0 +1,96 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { getUser, login, logout, userManager, type User } from './oidc';
import { triggerLoginRedirect } from './sessionRedirect';
import { Permission, mapGroupsToPermissions } from './permissions';
interface AuthContextType {
user: User | null;
isAuthenticated: boolean;
isLoading: boolean;
permissions: Permission[];
hasPermission: (permission: Permission) => boolean;
login: () => Promise<void>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [permissions, setPermissions] = useState<Permission[]>([]);
const [isLoading, setIsLoading] = useState(true);
const updatePermissions = (u: User | null) => {
if (!u) {
setPermissions([]);
return;
}
const groups = (u.profile as any).groups as string[] | undefined;
setPermissions(mapGroupsToPermissions(groups));
};
useEffect(() => {
getUser()
.then((u) => {
setUser(u);
updatePermissions(u);
})
.finally(() => setIsLoading(false));
const onUserLoaded = (u: User) => {
console.log('OIDC: User loaded/renewed');
setUser(u);
updatePermissions(u);
};
const onUserUnloaded = () => {
console.log('OIDC: User unloaded');
setUser(null);
updatePermissions(null);
};
const onSilentRenewError = (err: Error) => {
console.error('OIDC: Silent renew failed:', err);
if (/login_required|interaction_required|invalid_grant/.test(err.message ?? '')) {
setUser(null);
updatePermissions(null);
triggerLoginRedirect();
}
};
userManager.events.addUserLoaded(onUserLoaded);
userManager.events.addUserUnloaded(onUserUnloaded);
userManager.events.addSilentRenewError(onSilentRenewError);
return () => {
userManager.events.removeUserLoaded(onUserLoaded);
userManager.events.removeUserUnloaded(onUserUnloaded);
userManager.events.removeSilentRenewError(onSilentRenewError);
};
}, []);
const hasPermission = (permission: Permission) => {
if (permissions.includes(Permission.MANAGE_ALL)) return true;
return permissions.includes(permission);
};
return (
<AuthContext.Provider
value={{
user,
isAuthenticated: !!user && !user.expired,
isLoading,
permissions,
hasPermission,
login,
logout,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}
+43
View File
@@ -0,0 +1,43 @@
import { UserManager, WebStorageStateStore, type User } from 'oidc-client-ts';
import { getEnv } from '../utils/env';
const redirectUri = getEnv('VITE_OIDC_REDIRECT_URI')
|| `${window.location.origin}/auth/callback`;
const userManager = new UserManager({
authority: getEnv('VITE_OIDC_AUTHORITY'),
client_id: getEnv('VITE_OIDC_CLIENT_ID'),
redirect_uri: redirectUri,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email groups offline_access',
userStore: new WebStorageStateStore({ store: window.localStorage }),
automaticSilentRenew: true,
monitorSession: false, // Deaktiviert Session-Monitoring via Iframe (vermeidet Cookie-Probleme)
loadUserInfo: true,
accessTokenExpiringNotificationTimeInSeconds: 60, // Erneuert 60s vor Ablauf des 5min Tokens
});
export async function login(): Promise<void> {
await userManager.signinRedirect();
}
export async function handleCallback(): Promise<User> {
return userManager.signinRedirectCallback();
}
export async function logout(): Promise<void> {
await userManager.signoutRedirect();
}
export async function getUser(): Promise<User | null> {
return userManager.getUser();
}
export async function getAccessToken(): Promise<string | null> {
const user = await getUser();
return user?.access_token ?? null;
}
export { userManager };
export type { User };
@@ -0,0 +1,44 @@
export const Permission = {
MANAGE_ALL: 'MANAGE_ALL',
PROCESS_MANUALLY: 'PROCESS_MANUALLY',
VIEW_MAIL: 'VIEW_MAIL',
VIEW_INBOX: 'VIEW_INBOX',
VIEW_SCANNER: 'VIEW_SCANNER',
MANAGE_SETTINGS: 'MANAGE_SETTINGS',
} as const;
export type Permission = typeof Permission[keyof typeof Permission];
export function mapGroupsToPermissions(groups: string[] | undefined | null): Permission[] {
const permissions = new Set<Permission>();
if (!groups || !Array.isArray(groups)) {
return [];
}
// Superuser
if (groups.includes('PM_Admin')) {
permissions.add(Permission.MANAGE_ALL);
permissions.add(Permission.PROCESS_MANUALLY);
permissions.add(Permission.VIEW_MAIL);
permissions.add(Permission.VIEW_INBOX);
permissions.add(Permission.VIEW_SCANNER);
permissions.add(Permission.MANAGE_SETTINGS);
return Array.from(permissions);
}
if (groups.includes('PM_Belege')) {
permissions.add(Permission.PROCESS_MANUALLY);
}
if (groups.includes('PM_Maileingang')) {
permissions.add(Permission.VIEW_MAIL);
}
if (groups.includes('PM_Posteingang')) {
permissions.add(Permission.VIEW_INBOX);
}
if (groups.includes('PM_Scanner')) {
permissions.add(Permission.VIEW_SCANNER);
}
return Array.from(permissions);
}
@@ -0,0 +1,27 @@
import { userManager } from './oidc';
const KEY = 'pm.returnUrl';
let redirecting = false;
export function saveReturnUrl(path: string): void {
if (path === '/login' || path.startsWith('/auth/callback')) return;
sessionStorage.setItem(KEY, path);
}
export function consumeReturnUrl(): string {
const v = sessionStorage.getItem(KEY) ?? '/';
sessionStorage.removeItem(KEY);
return v;
}
export async function triggerLoginRedirect(): Promise<void> {
if (redirecting) return;
redirecting = true;
saveReturnUrl(window.location.pathname + window.location.search);
try {
await userManager.removeUser();
} catch (err) {
console.error('OIDC: removeUser before redirect failed', err);
}
await userManager.signinRedirect();
}
@@ -0,0 +1,304 @@
import { useState, useRef, useEffect, useCallback } from 'react';
import { Spin } from 'antd';
import QRCode from 'qrcode';
import { getEnv } from '../utils/env';
import { getAccessToken } from '../auth/oidc';
// A4 page dimensions in mm
const PAGE_WIDTH_MM = 210;
const PAGE_HEIGHT_MM = 297;
// Barcode label dimensions in mm (from C# template)
const BARCODE_WIDTH_MM = 57;
const BARCODE_HEIGHT_MM = 32;
interface BarcodePositionerProps {
attachmentId: number;
startPage?: number;
belegnummer: string;
isNeu?: boolean;
datum?: string;
jahr?: string;
position: { x: number; y: number };
onPositionChange: (pos: { x: number; y: number }) => void;
}
export default function BarcodePositioner({
attachmentId,
startPage,
belegnummer,
isNeu,
datum,
jahr,
position,
onPositionChange,
}: BarcodePositionerProps) {
const containerRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
const [imgSrc, setImgSrc] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string>('');
const [dragging, setDragging] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 });
const [innerWidth, setInnerWidth] = useState(0);
// Generate preview QR code
useEffect(() => {
QRCode.toDataURL('Vorschau', {
margin: 0,
width: 200,
errorCorrectionLevel: 'H'
}).then(setQrDataUrl);
}, []);
// Load first page preview image
useEffect(() => {
let objectUrl: string;
const page = startPage || 1;
const url = `${getEnv('VITE_API_URL')}/api/email-import/attachments/${attachmentId}/pages/${page}/preview`;
getAccessToken().then(token => {
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => {
if (!res.ok) throw new Error('Not found');
return res.blob();
})
.then(blob => {
objectUrl = URL.createObjectURL(blob);
setImgSrc(objectUrl);
})
.catch(() => {});
});
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [attachmentId, startPage]);
// Measure inner page width
useEffect(() => {
const measure = () => {
if (innerRef.current) {
setInnerWidth(innerRef.current.getBoundingClientRect().width);
}
};
measure();
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}, [imgSrc]);
// Scale factor: pixels per mm
const scale = innerWidth > 0 ? innerWidth / PAGE_WIDTH_MM : 1;
const barcodeW = BARCODE_WIDTH_MM * scale;
const barcodeH = BARCODE_HEIGHT_MM * scale;
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
dragOffset.current = { x: e.clientX - rect.left, y: e.clientY - rect.top };
setDragging(true);
};
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!dragging || !innerRef.current) return;
const cr = innerRef.current.getBoundingClientRect();
let px = e.clientX - cr.left - dragOffset.current.x;
let py = e.clientY - cr.top - dragOffset.current.y;
// account for scroll offset
if (containerRef.current) {
py += containerRef.current.scrollTop;
}
// Constraints: 6mm margin from edge
const margin = 6 * scale;
px = Math.max(margin, Math.min(px, cr.width - barcodeW - margin));
const pageHeightPx = PAGE_HEIGHT_MM * scale;
py = Math.max(margin, Math.min(py, pageHeightPx - barcodeH - margin));
onPositionChange({ x: Math.round(px / scale), y: Math.round(py / scale) });
},
[dragging, scale, barcodeW, barcodeH, onPositionChange],
);
const handleMouseUp = useCallback(() => setDragging(false), []);
useEffect(() => {
if (dragging) {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
}
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [dragging, handleMouseMove, handleMouseUp]);
const barcodeLeft = position.x * scale;
const barcodeTop = position.y * scale;
// Format the date for display
const displayDate = datum ? (() => {
const d = new Date(datum);
if (isNaN(d.getTime())) return datum;
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
})() : '';
if (!imgSrc) {
return (
<div style={{ height: 400, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#fafafa', border: '1px solid #d9d9d9', borderRadius: 4 }}>
<Spin />
</div>
);
}
// QR Size: 30mm, Position: 3.2mm - 4px (~1mm) = 2.2mm from left
// QR Size: 27mm, Position: 2.5mm
const qrSize = 27 * scale;
const qrLeft = 2.5 * scale;
const qrTop = 2.5 * scale;
// Text area X: 33.3mm, Width: 21mm
const textAreaLeft = 33.3 * scale;
const textAreaWidth = 21 * scale;
return (
<div
ref={containerRef}
style={{
height: 400,
overflow: 'auto',
border: '1px solid #d9d9d9',
borderRadius: 4,
background: '#e8e8e8',
}}
>
<div
ref={innerRef}
style={{
position: 'relative',
width: '100%',
paddingBottom: `${(PAGE_HEIGHT_MM / PAGE_WIDTH_MM) * 100}%`,
background: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
}}
>
<img
src={imgSrc}
alt="Seite 1"
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'fill', display: 'block' }}
draggable={false}
/>
<div
onMouseDown={handleMouseDown}
style={{
position: 'absolute',
left: barcodeLeft,
top: barcodeTop,
width: barcodeW,
height: barcodeH,
border: '1px solid #000',
background: '#fff',
cursor: dragging ? 'grabbing' : 'grab',
userSelect: 'none',
zIndex: 10,
boxShadow: dragging ? '0 4px 12px rgba(0,0,0,0.25)' : '0 1px 3px rgba(0,0,0,0.1)',
overflow: 'hidden',
}}
>
{/* QR code image */}
<div
style={{
position: 'absolute',
left: qrLeft,
top: qrTop,
width: qrSize,
height: qrSize,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{qrDataUrl ? (
<img src={qrDataUrl} alt="QR Vorschau" style={{ width: '100%', height: '100%', display: 'block' }} />
) : (
<div style={{ fontSize: 8 }}>...</div>
)}
</div>
<div
style={{
position: 'absolute',
left: textAreaLeft,
top: 0,
width: textAreaWidth,
height: '100%',
display: 'flex',
flexDirection: 'column',
fontFamily: '"Times New Roman", Times, serif',
}}
>
{/* Year */}
<div style={{
marginTop: 3 * scale,
height: 7.5 * scale,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12 * scale * 0.35,
fontWeight: 700,
color: '#000',
}}>
{String(jahr || '').padStart(4, '0')}
</div>
{/* Sequential Number */}
<div style={{
height: 7.5 * scale,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 12 * scale * 0.35,
fontWeight: 700,
color: '#000',
}}>
{isNeu ? '← neu →' : String(belegnummer || '').padStart(6, '0')}
</div>
{/* "Eingegangen" - Smaller and closer */}
<div style={{
marginTop: 1 * scale, // Gap to number
height: 4 * scale,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 8 * scale * 0.35,
fontWeight: 700,
color: '#000',
lineHeight: 1
}}>
Eingegangen
</div>
{/* Date - Smaller and closer */}
<div style={{
height: 4 * scale,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 8 * scale * 0.35,
fontWeight: 700,
color: '#000',
lineHeight: 1
}}>
{displayDate}
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,541 @@
import { useEffect, useState, useCallback } from 'react';
import { Modal, Form, Select, DatePicker, Input, Spin, message, Row, Col, Button, Space, Divider } from 'antd';
import { PlusOutlined, EyeOutlined, SearchOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { posteingangApi } from '../api/posteingang';
import type { DocumentRequirement, PosteingangDocument, Kontonummer } from '../api/posteingang';
import { clientsApi } from '../api/inbox';
import type { Client } from '../api/inbox';
import { paperlessApi } from '../api/paperless';
import type { PaperlessDocType, PaperlessCorrespondent } from '../api/paperless';
import { getEnv } from '../utils/env';
import DocumentSearchModal from './DocumentSearchModal';
const { Option } = Select;
interface Props {
documentId: number | null;
document: PosteingangDocument | null;
open: boolean;
onClose: (next?: boolean) => void;
onSave: () => void;
isPosteingang?: boolean;
hasNextDocument?: boolean;
}
export default function DocumentEditModal({ documentId, document, open, onClose, onSave, isPosteingang = true, hasNextDocument = true }: Props) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [clients, setClients] = useState<Client[]>([]);
const [documentTypes, setDocumentTypes] = useState<PaperlessDocType[]>([]);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [requirements, setRequirements] = useState<DocumentRequirement[]>([]);
const [kontonummerMissing, setKontonummerMissing] = useState<{ correspondentId: number, nummer: string } | null>(null);
const [docTitles, setDocTitles] = useState<Record<number, string>>({});
const [searchModalOpen, setSearchModalOpen] = useState<{ field: string, reqId: number } | null>(null);
// Kontonummer state
const [kontonummern, setKontonummern] = useState<Kontonummer[]>([]);
const [kontonummernLoading, setKontonummernLoading] = useState(false);
const [newKontonummer, setNewKontonummer] = useState('');
const selectedCorrespondent = Form.useWatch('correspondent', form);
const loadKontonummern = useCallback(async (correspondentId: number) => {
setKontonummernLoading(true);
try {
const data = await posteingangApi.getKontonummern(correspondentId);
setKontonummern(data);
} catch {
setKontonummern([]);
} finally {
setKontonummernLoading(false);
}
}, []);
useEffect(() => {
if (selectedCorrespondent) {
loadKontonummern(selectedCorrespondent);
} else {
setKontonummern([]);
}
}, [selectedCorrespondent, loadKontonummern]);
const handleAddKontonummer = async () => {
const trimmed = newKontonummer.trim();
if (!trimmed || !selectedCorrespondent) return;
try {
await posteingangApi.createKontonummer({ correspondentId: selectedCorrespondent, nummer: trimmed });
message.success(`Kontonummer "${trimmed}" hinzugefügt.`);
setNewKontonummer('');
await loadKontonummern(selectedCorrespondent);
form.setFieldValue('cf_5', trimmed);
} catch {
message.error('Kontonummer konnte nicht angelegt werden.');
}
};
useEffect(() => {
const resolveLinkTitles = async () => {
const linkFields = requirements.filter(r => r.feldTyp === 'documentlink');
for (const req of linkFields) {
const fieldName = req.isCustomField ? `cf_${req.customFieldIndex}` : req.feldId;
const val = form.getFieldValue(fieldName);
if (val && !docTitles[val]) {
try {
const d = await paperlessApi.getDocument(val);
setDocTitles(prev => ({ ...prev, [val]: d.title }));
} catch (e) {
// Handle or ignore
}
}
}
};
if (open && requirements.length > 0) {
resolveLinkTitles();
}
}, [requirements, open]); // Removed docTitles to avoid loop, we check val existence anyway
useEffect(() => {
if (open) {
loadInitialData();
} else {
form.resetFields();
setRequirements([]);
setKontonummerMissing(null);
setKontonummern([]);
setNewKontonummer('');
}
}, [open]);
useEffect(() => {
if (open && document) {
let customFieldsObj: any = {};
if (document.customFields) {
document.customFields.forEach(cf => {
customFieldsObj[`cf_${cf.field}`] = cf.value;
});
}
form.setFieldsValue({
mandant: document.owner,
documentType: document.documentType,
correspondent: document.correspondent,
title: document.title,
asn: document.asn,
belegdatum: document.created ? dayjs(document.created) : null,
eingangsdatum: customFieldsObj['cf_9'] ? dayjs(customFieldsObj['cf_9']) : (document.added ? dayjs(document.added) : null),
...customFieldsObj,
});
if (document.documentType) {
fetchRequirements(document.documentType);
}
}
}, [document, form, open]);
const ensureCorrespondentInList = async (correspondentId: number | null | undefined) => {
if (!correspondentId) return;
setCorrespondents(prev => {
const exists = prev.some(c => Number(c.id) === Number(correspondentId));
if (!exists) {
// We need to fetch it. But we can't easily do it inside setCorrespondents.
// So we'll do it outside.
return prev;
}
return prev;
});
const exists = correspondents.some(c => Number(c.id) === Number(correspondentId));
if (!exists) {
try {
const currentCorr = await paperlessApi.getCorrespondent(correspondentId);
if (currentCorr) {
setCorrespondents(prev => {
if (prev.some(c => Number(c.id) === Number(currentCorr.id))) return prev;
return [...prev, currentCorr];
});
}
} catch (e) {
console.error("Fehler beim Laden des zugewiesenen Absenders:", e);
}
}
};
useEffect(() => {
if (open && document?.correspondent) {
ensureCorrespondentInList(document.correspondent);
}
}, [document?.correspondent, open]);
const loadInitialData = async () => {
setLoading(true);
try {
const [clientsData, docTypesData, correspondentsData] = await Promise.all([
clientsApi.getMyClients(),
paperlessApi.getDocumentTypes(),
paperlessApi.getCorrespondents()
]);
setClients(clientsData);
setDocumentTypes(docTypesData);
setCorrespondents(correspondentsData);
// If document is already there, ensure its correspondent is in the list
if (document?.correspondent) {
const exists = correspondentsData.some(c => Number(c.id) === Number(document.correspondent));
if (!exists) {
const currentCorr = await paperlessApi.getCorrespondent(document.correspondent);
if (currentCorr) {
setCorrespondents([...correspondentsData, currentCorr]);
}
}
}
} catch (e) {
message.error("Fehler beim Laden der Stammdaten.");
} finally {
setLoading(false);
}
};
const fetchRequirements = async (docType: number) => {
try {
const reqs = await posteingangApi.getRequirements(docType, isPosteingang);
setRequirements(reqs);
} catch (e) {
message.error("Fehler beim Laden der Pflichtfelder.");
}
};
const [fetchingCorrespondents, setFetchingCorrespondents] = useState(false);
const [searchTimeout, setSearchTimeout] = useState<any>(null);
const handleCorrespondentSearch = async (value: string) => {
if (searchTimeout) clearTimeout(searchTimeout);
setSearchTimeout(setTimeout(async () => {
setFetchingCorrespondents(true);
try {
const data = await paperlessApi.getCorrespondents(value);
setCorrespondents(data);
} catch (e) {
message.error("Absender konnten nicht geladen werden.");
} finally {
setFetchingCorrespondents(false);
}
}, 500));
};
const handleDocumentTypeChange = (value: number) => {
fetchRequirements(value);
};
const handleSaveDocument = async (values: any, isNext: boolean = false) => {
// Collect custom fields into an array
const customFieldsObj: any = {};
Object.keys(values).forEach(key => {
if (key.startsWith('cf_')) {
const fieldId = parseInt(key.replace('cf_', ''), 10);
customFieldsObj[fieldId] = values[key];
}
});
if (values.eingangsdatum) {
customFieldsObj[9] = values.eingangsdatum.format('YYYY-MM-DD');
} else {
customFieldsObj[9] = null;
}
const payload = {
mandant: values.mandant,
documentType: values.documentType,
correspondent: values.correspondent,
title: values.title,
date: values.belegdatum ? values.belegdatum.format('YYYY-MM-DD') : null,
customFields: customFieldsObj,
};
setSaving(true);
try {
await posteingangApi.updateDocument(documentId!, payload);
message.success("Dokument erfolgreich aktualisiert.");
onSave();
onClose(isNext);
} catch (e) {
message.error("Fehler beim Speichern des Dokuments.");
} finally {
setSaving(false);
}
};
const handleSubmit = async (isNext: boolean = false) => {
try {
const values = await form.validateFields();
// Kontonummer Logic
const kontonummerReq = requirements.find(r => r.customFieldIndex === 5);
if (kontonummerReq && values[`cf_5`] && values.correspondent) {
const kNummer = values[`cf_5`];
const knData = await posteingangApi.getKontonummern(values.correspondent);
const exists = knData.some(k => k.Nummer === kNummer);
if (!exists) {
// Prompt user to save kontonummer
setKontonummerMissing({ correspondentId: values.correspondent, nummer: kNummer });
return; // Stop saving, wait for confirmation
}
}
await handleSaveDocument(values, isNext);
} catch (e) {
// Validation failed
console.error("Form validation failed:", e);
}
};
const confirmKontonummerSave = async (shouldSave: boolean, isNext: boolean = false) => {
if (shouldSave && kontonummerMissing) {
try {
await posteingangApi.createKontonummer(kontonummerMissing);
message.success("Kontonummer hinterlegt.");
} catch (e) {
message.error("Fehler beim Speichern der Kontonummer.");
}
}
setKontonummerMissing(null);
const values = form.getFieldsValue();
await handleSaveDocument(values, isNext);
};
const handleSelectLink = (doc: any) => {
if (searchModalOpen) {
form.setFieldValue(searchModalOpen.field, doc.id);
setDocTitles(prev => ({ ...prev, [doc.id]: doc.title }));
setSearchModalOpen(null);
form.validateFields([searchModalOpen.field]);
}
};
if (documentId === null) return null;
return (
<>
<Modal
title={`Dokument bearbeiten (${document?.title || ''})`}
open={open && !kontonummerMissing}
onCancel={() => onClose(false)}
width={1400}
style={{ top: 20 }}
footer={
hasNextDocument ? [
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
<Button key="save" type="primary" loading={saving} onClick={() => handleSubmit(false)}>Speichern</Button>,
<Button key="saveNext" type="primary" ghost loading={saving} onClick={() => handleSubmit(true)}>Speichern & Nächstes</Button>
] : [
<Button key="cancel" onClick={() => onClose(false)}>Abbrechen</Button>,
<Button key="save" type="primary" loading={saving} onClick={() => handleSubmit(false)}>Speichern</Button>
]
}
>
<Spin spinning={loading}>
<Row gutter={16} style={{ height: '75vh', overflow: 'hidden' }}>
<Col span={10} style={{ 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' }]}>
<Select showSearch optionFilterProp="children" allowClear>
{clients.map(c => (
<Option key={c.Id} value={c.PaperlessUserId}>{c.Name}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="documentType" label="Dokumentart" rules={[{ required: true, message: 'Wähle eine Dokumentart' }]}>
<Select showSearch optionFilterProp="children" onChange={handleDocumentTypeChange}>
{documentTypes.map(d => (
<Option key={d.id} value={d.id}>{d.name}</Option>
))}
</Select>
</Form.Item>
<Form.Item name="belegdatum" label="Belegdatum">
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
</Form.Item>
<Form.Item name="eingangsdatum" label="Eingangsdatum">
<DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />
</Form.Item>
{/* Rendering dynamic requirements based on DocumentType */}
{requirements.map(req => {
if (req.feldId === '2' || (req.isCustomField && req.customFieldIndex === 9)) {
return null;
}
let inputElement = <Input />;
if (req.isCustomField && req.customFieldIndex === 5) {
// Kontonummer — Select mit Möglichkeit neue anzulegen
inputElement = (
<Select
showSearch
allowClear
optionFilterProp="children"
placeholder={!selectedCorrespondent ? 'Bitte zuerst einen Absender wählen' : 'Kontonummer wählen'}
disabled={!selectedCorrespondent}
loading={kontonummernLoading}
notFoundContent={kontonummernLoading ? <Spin size="small" /> : 'Keine Kontonummern vorhanden'}
dropdownRender={(menu) => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<div style={{ display: 'flex', gap: 8, padding: '0 8px 8px' }}>
<Input
placeholder="Neue Kontonummer"
value={newKontonummer}
onChange={(e) => setNewKontonummer(e.target.value)}
onKeyDown={(e) => e.stopPropagation()}
/>
<Button
type="text"
icon={<PlusOutlined />}
onClick={handleAddKontonummer}
disabled={!newKontonummer.trim()}
>
Anlegen
</Button>
</div>
</>
)}
>
{kontonummern.map((k) => (
<Option key={k.KontonummerId ?? k.Nummer} value={k.Nummer}>
{k.Nummer}
</Option>
))}
</Select>
);
} else if (req.feldId === '1') {
inputElement = (
<Select
showSearch
filterOption={false}
onSearch={handleCorrespondentSearch}
notFoundContent={fetchingCorrespondents ? <Spin size="small" /> : null}
>
{correspondents.map(c => (
<Option key={c.id} value={c.id}>{c.name}</Option>
))}
</Select>
);
} else if (req.feldTyp === 'date') {
inputElement = <DatePicker style={{ width: '100%' }} format="DD.MM.YYYY" />;
} else if (req.feldTyp === 'select' && req.fieldOptions) {
inputElement = (
<Select showSearch optionFilterProp="children">
{req.fieldOptions.map(opt => (
<Option key={opt.id} value={opt.id}>{opt.label}</Option>
))}
</Select>
);
} else if (req.feldTyp === 'documentlink') {
const fieldName = req.isCustomField ? `cf_${req.customFieldIndex}` :
req.feldId === '1' ? 'correspondent' :
req.feldId === '2' ? 'belegdatum' :
req.feldId === '3' ? 'asn' :
req.feldId === '5' ? 'title' : req.feldId;
const currentId = form.getFieldValue(fieldName);
inputElement = (
<Input
readOnly
placeholder="Klicken zum Suchen..."
value={currentId ? (docTitles[currentId] || `Dokument #${currentId}`) : ''}
onClick={() => setSearchModalOpen({ field: fieldName, reqId: req.id })}
suffix={
<Space>
{currentId && (
<Button
type="text"
size="small"
icon={<EyeOutlined />}
onClick={(e) => {
e.stopPropagation();
window.open(`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${currentId}`, '_blank');
}}
/>
)}
<Button
type="text"
size="small"
icon={<SearchOutlined />}
onClick={(e) => {
e.stopPropagation();
setSearchModalOpen({ field: fieldName, reqId: req.id });
}}
/>
</Space>
}
/>
);
}
const name = req.isCustomField ? `cf_${req.customFieldIndex}` :
req.feldId === '1' ? 'correspondent' :
req.feldId === '2' ? 'belegdatum' :
req.feldId === '3' ? 'asn' :
req.feldId === '5' ? 'title' : req.feldId;
return (
<Form.Item
key={req.id}
name={name}
label={req.feldName}
rules={[{ required: req.required, message: `${req.feldName} ist ein Pflichtfeld` }]}
tooltip={req.hinweis}
>
{inputElement}
</Form.Item>
);
})}
</Form>
</Col>
<Col span={14} style={{ height: '100%' }}>
<iframe
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/pdf/${documentId}#toolbar=0`}
style={{ width: '100%', height: '100%', border: 'none' }}
title="PDF Preview"
/>
</Col>
</Row>
</Spin>
</Modal>
{/* Kontonummer Dialog */}
<Modal
title="Neue Kontonummer hinterlegen?"
open={!!kontonummerMissing}
onCancel={() => confirmKontonummerSave(false)}
onOk={() => confirmKontonummerSave(true)}
okText="Hinterlegen"
cancelText="Nein, nur Speichern"
>
<p>Die Kontonummer <b>{kontonummerMissing?.nummer}</b> ist bei diesem Absender noch nicht hinterlegt.</p>
<p>Möchtest du sie dem Absender fest zuordnen?</p>
</Modal>
<DocumentSearchModal
open={!!searchModalOpen}
onCancel={() => setSearchModalOpen(null)}
onSelect={handleSelectLink}
/>
</>
);
}
@@ -0,0 +1,124 @@
import { useState, useCallback, useEffect } from 'react';
import { Modal, Input, List, Image, Typography, Space, Pagination, Spin, Button } from 'antd';
import { SearchOutlined, CheckOutlined } from '@ant-design/icons';
import { paperlessApi } from '../api/paperless';
import { getEnv } from '../utils/env';
import dayjs from 'dayjs';
const { Text } = Typography;
interface Props {
open: boolean;
onCancel: () => void;
onSelect: (doc: any) => void;
}
export default function DocumentSearchModal({ open, onCancel, onSelect }: Props) {
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(10);
const load = useCallback(async (q: string, p: number) => {
setLoading(true);
try {
const response = await paperlessApi.searchDocuments({
search: q,
page: p,
pageSize: pageSize,
});
setData(response.results);
setTotal(response.count);
} catch (e) {
console.error("Error searching documents", e);
} finally {
setLoading(false);
}
}, [pageSize]);
useEffect(() => {
if (open) {
load(search, page);
}
}, [open, page, load]); // We don't trigger on search immediately to allow 'Search' button or Enter
const handleSearch = (val: string) => {
setSearch(val);
setPage(1);
load(val, 1);
};
return (
<Modal
title="Dokument suchen"
open={open}
onCancel={onCancel}
footer={null}
width={800}
style={{ top: 50 }}
>
<div style={{ marginBottom: 16 }}>
<Input.Search
placeholder="Titel oder Inhalt suchen..."
enterButton={<SearchOutlined />}
onSearch={handleSearch}
loading={loading}
allowClear
/>
</div>
<Spin spinning={loading}>
<List
itemLayout="horizontal"
dataSource={data}
renderItem={(doc) => (
<List.Item
actions={[
<Button
key="select"
type="primary"
icon={<CheckOutlined />}
onClick={() => onSelect(doc)}
>
Auswählen
</Button>
]}
>
<List.Item.Meta
avatar={
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${doc.id}`}
width={80}
height={110}
style={{ objectFit: 'cover', borderRadius: 4, border: '1px solid #f0f0f0' }}
preview={false}
/>
}
title={doc.title}
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>
</Space>
}
/>
</List.Item>
)}
/>
{total > pageSize && (
<div style={{ marginTop: 16, textAlign: 'right' }}>
<Pagination
current={page}
pageSize={pageSize}
total={total}
onChange={(p) => setPage(p)}
size="small"
/>
</div>
)}
</Spin>
</Modal>
);
}
@@ -0,0 +1,746 @@
import { useState, useEffect } from 'react';
import {
Modal, Steps, Button, Table, Radio, Select, Input, DatePicker,
Space, Row, Col, Typography, message, Spin, Result, Alert, Card, Tag
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { emailImportApi, type AttachmentImportData } from '../api/email-import';
import { paperlessApi, type PaperlessCorrespondent } from '../api/paperless';
import dayjs from 'dayjs';
import {
PrinterOutlined, FileTextOutlined, PaperClipOutlined, ArrowRightOutlined,
WarningOutlined
} from '@ant-design/icons';
import PdfSplitViewer from './PdfSplitViewer';
import BarcodePositioner from './BarcodePositioner';
const { Text, Title } = Typography;
interface MailImportWizardProps {
visible: boolean;
onClose: () => void;
email: any;
attachments: any[];
}
export default function MailImportWizard({ visible, onClose, email, attachments }: MailImportWizardProps) {
const [currentStep, setCurrentStep] = useState(0);
const [importData, setImportData] = useState<AttachmentImportData[]>([]);
const [loading, setLoading] = useState(false);
const [correspondents, setCorrespondents] = useState<PaperlessCorrespondent[]>([]);
const [suggestedCorrespondentId, setSuggestedCorrespondentId] = useState<number | null>(null);
// Step 2 specific state
const [belegnummern, setBelegnummern] = useState<Record<string, string>>({});
const [barcodes, setBarcodes] = useState<Record<string, any>>({});
// Step 1 expand/collapse state
const [expandedRows, setExpandedRows] = useState<string[]>([]);
// Belegnummer mode per item: 'neu' (auto from API) or 'manuell'
const [belegnummerMode, setBelegnummerMode] = useState<Record<string, 'neu' | 'manuell'>>({});
// Eingangsdatum per item
const [eingangsdaten, setEingangsdaten] = useState<Record<string, dayjs.Dayjs>>({});
// Step 3 specific state
const [importSuccess, setImportSuccess] = useState(false);
useEffect(() => {
if (visible && attachments.length > 0) {
// Initialize import data
const initialData = attachments.map(att => ({
attachmentId: att.Id,
virtualId: `${att.Id}_full`,
type: 'MAIN' as 'MAIN' | 'ATTACHMENT' | 'IGNORE',
fileName: att.FileName,
pages: undefined, // full document initially
}));
setImportData(initialData);
setExpandedRows(initialData.length > 0 ? [initialData[0].virtualId] : []);
// Initialize eingangsdaten and barcodes with email date
const mailDate = dayjs(email.Date);
const initialDates: Record<string, dayjs.Dayjs> = {};
const initialBarcodes: Record<string, any> = {};
initialData.forEach(d => {
initialDates[d.virtualId] = mailDate;
initialBarcodes[d.virtualId] = {
x: 6,
y: 6,
datum: mailDate.format('YYYY-MM-DD'),
jahr: mailDate.format('YYYY'),
isNeu: true,
nummer: '000000'
};
});
setEingangsdaten(initialDates);
setBarcodes(initialBarcodes);
// Load correspondents and try to find suggestion
loadCorrespondents();
// Check for duplicates in Paperless
checkDuplicates(initialData);
}
}, [visible, attachments]);
const checkDuplicates = async (initialData: AttachmentImportData[]) => {
setLoading(true);
try {
const updatedData = [...initialData];
for (let i = 0; i < updatedData.length; i++) {
const item = updatedData[i];
const attachment = attachments.find(a => a.Id === item.attachmentId);
if (attachment && attachment.Checksum) {
const exists = await paperlessApi.checksumExists(attachment.Checksum);
if (exists) {
updatedData[i] = { ...updatedData[i], type: 'IGNORE', isDuplicate: true };
}
}
}
setImportData(updatedData);
} catch (e) {
console.error('Error checking duplicates', e);
} finally {
setLoading(false);
}
};
const loadCorrespondents = async () => {
try {
const data = await paperlessApi.getCorrespondents();
setCorrespondents(data || []);
// Try to find matching correspondent by email
// We parse the from address, which might be "Name <email@domain.com>"
const match = email.From.match(/<([^>]+)>/);
const emailAddress = match ? match[1] : email.From;
const mapping = await emailImportApi.getCorrespondentByEmail(emailAddress);
if (mapping && mapping.paperlessCorrespondentId) {
setSuggestedCorrespondentId(mapping.paperlessCorrespondentId);
setImportData(prev => prev.map(item => ({ ...item, paperlessCorrespondentId: mapping.paperlessCorrespondentId })));
}
} catch (e) {
// silently fail
}
};
const updateImportData = (virtualId: string, key: string, value: any) => {
setImportData(prev => prev.map(item => item.virtualId === virtualId ? { ...item, [key]: value } : item));
};
const handleSplit = (virtualId: string, splitPage: number) => {
setImportData(prev => {
const idx = prev.findIndex(i => i.virtualId === virtualId);
if (idx === -1) return prev;
const itemToSplit = prev[idx];
const start = itemToSplit.pages?.start || 1;
const end = itemToSplit.pages?.end || 999; // 999 means to the end
const part1 = { ...itemToSplit, virtualId: `${itemToSplit.attachmentId}_${start}_${splitPage}`, pages: { start, end: splitPage }, fileName: `${itemToSplit.fileName} (Teil 1)` };
const part2 = { ...itemToSplit, virtualId: `${itemToSplit.attachmentId}_${splitPage+1}_${end}`, pages: { start: splitPage + 1, end }, fileName: `${itemToSplit.fileName} (Teil 2)` };
// Propagate date and barcode
const parentDate = eingangsdaten[virtualId] || dayjs(email.Date);
const parentBarcode = barcodes[virtualId];
setEingangsdaten(prev => ({
...prev,
[part1.virtualId]: parentDate,
[part2.virtualId]: parentDate,
}));
if (parentBarcode) {
setBarcodes(prev => ({
...prev,
[part1.virtualId]: { ...parentBarcode },
[part2.virtualId]: { ...parentBarcode },
}));
}
const newArray = [...prev];
newArray.splice(idx, 1, part1, part2);
return newArray;
});
};
const loadBelegnummern = async () => {
// No longer fetching from API here, just initializing state for Step 2
for (const item of importData) {
if (item.type === 'IGNORE') continue;
const vid = item.virtualId;
const mode = belegnummerMode[vid] || 'neu';
const itemDate = eingangsdaten[vid] || dayjs(email.Date);
const dateStr = itemDate.format('YYYY-MM-DD');
const jahr = itemDate.format('YYYY');
if (mode === 'neu') {
setBarcodes(prev => ({
...prev,
[vid]: { ...prev[vid], x: prev[vid]?.x ?? 6, y: prev[vid]?.y ?? 6, datum: dateStr, jahr, isNeu: true, nummer: '000000' }
}));
} else if (mode === 'manuell') {
const manJahr = barcodes[vid]?.jahr || jahr;
const manNummer = belegnummern[vid] || '000000';
setBelegnummern(prev => ({ ...prev, [vid]: manNummer }));
setBarcodes(prev => ({
...prev,
[vid]: { ...prev[vid], x: prev[vid]?.x ?? 6, y: prev[vid]?.y ?? 6, datum: dateStr, jahr: manJahr, nummer: manNummer, isNeu: false }
}));
}
if (!eingangsdaten[vid]) {
setEingangsdaten(prev => ({ ...prev, [vid]: itemDate }));
}
if (!belegnummerMode[vid]) setBelegnummerMode(prev => ({ ...prev, [vid]: 'neu' }));
}
};
const preFetchBelegnummern = async () => {
setLoading(true);
try {
const newBelegnummern = { ...belegnummern };
const newBarcodes = { ...barcodes };
for (const item of importData) {
if (item.type === 'IGNORE') continue;
const vid = item.virtualId;
const mode = belegnummerMode[vid] || 'neu';
if (mode === 'neu' && (!newBelegnummern[vid] || newBelegnummern[vid] === '000000')) {
const itemDate = eingangsdaten[vid] || dayjs(email.Date);
const dateStr = itemDate.format('YYYY-MM-DD');
try {
const res = await emailImportApi.getBelegnummer(dateStr);
let num = res.nummer;
let yr = itemDate.format('YYYY');
if (num.includes('-')) {
const parts = num.split('-');
if (parts.length === 2 && parts[0].length === 4) {
yr = parts[0];
num = parts[1];
}
}
newBelegnummern[vid] = num;
newBarcodes[vid] = {
...newBarcodes[vid],
nummer: num,
jahr: yr,
datum: dateStr,
isNeu: false // We show it as "fixed" in summary
};
} catch (e) {
message.error(`Belegnummer für ${item.fileName} konnte nicht geladen werden.`);
}
}
}
setBelegnummern(newBelegnummern);
setBarcodes(newBarcodes);
} finally {
setLoading(false);
}
};
const handleBack = async () => {
if (currentStep === 2) {
setLoading(true);
try {
for (const item of importData) {
const vid = item.virtualId;
const mode = belegnummerMode[vid] || 'neu';
const num = belegnummern[vid];
if (mode === 'neu' && num && num !== '000000') {
const dateStr = (eingangsdaten[vid] || dayjs(email.Date)).format('YYYY-MM-DD');
await emailImportApi.releaseBelegnummer(dateStr, num);
}
}
// Clear numbers for "neu" mode so they get re-fetched
const clearedBelegnummern = { ...belegnummern };
const clearedBarcodes = { ...barcodes };
for (const vid in belegnummerMode) {
if (belegnummerMode[vid] === 'neu') {
clearedBelegnummern[vid] = '000000';
if (clearedBarcodes[vid]) {
clearedBarcodes[vid] = { ...clearedBarcodes[vid], nummer: '000000', isNeu: true };
}
}
}
setBelegnummern(clearedBelegnummern);
setBarcodes(clearedBarcodes);
} catch (e) {
console.error('Failed to release numbers', e);
} finally {
setLoading(false);
}
}
setCurrentStep(currentStep - 1);
};
const nextStep = async () => {
if (currentStep === 0) {
await loadBelegnummern();
} else if (currentStep === 1) {
await preFetchBelegnummern();
}
setCurrentStep(currentStep + 1);
};
const executeImport = async () => {
setLoading(true);
try {
const finalData = [];
for (const item of importData) {
if (item.type === 'IGNORE') continue;
let num = belegnummern[item.virtualId] || '000000';
let yr = barcodes[item.virtualId]?.jahr || eingangsdaten[item.virtualId]?.format('YYYY') || dayjs(email.Date).format('YYYY');
const mode = belegnummerMode[item.virtualId] || 'neu';
if (mode === 'neu' && (!num || num === '000000')) {
// Fallback in case pre-fetch failed or was skipped
const dateStr = (eingangsdaten[item.virtualId] || dayjs(email.Date)).format('YYYY-MM-DD');
try {
const res = await emailImportApi.getBelegnummer(dateStr);
num = res.nummer;
if (num.includes('-')) {
const parts = num.split('-');
if (parts.length === 2 && parts[0].length === 4) {
yr = parts[0];
num = parts[1];
}
}
} catch (e) {
throw new Error(`Konnte keine neue Belegnummer für ${item.fileName} abrufen.`);
}
} else {
// Manuell or already fetched: Ensure num is just the 6-digit part if it contains a dash
if (num.includes('-')) {
num = num.split('-')[1];
}
}
const finalBelegnummer = `${yr}-${String(num).padStart(6, '0')}`;
finalData.push({
...item,
splitRanges: item.pages ? [item.pages] : undefined,
barcode: { ...barcodes[item.virtualId], nummer: num, jahr: yr, isNeu: false },
belegnummer: finalBelegnummer,
});
}
await emailImportApi.executeImport(email.Date, finalData);
setImportSuccess(true);
setCurrentStep(2);
} catch (e: any) {
message.error(`Fehler beim Import: ${e.message}`);
} finally {
setLoading(false);
}
};
const printDocument = async (virtualId: string, attachmentId: number) => {
// Open a new tab immediately to satisfy pop-up blockers
const printWindow = window.open('', '_blank');
if (!printWindow) {
message.warning('Bitte Pop-ups erlauben, um direkt zu drucken.');
return;
}
printWindow.document.write('<html><head><title>Druckvorschau</title></head><body style="margin:0;display:flex;justify-content:center;align-items:center;height:100vh;background:#f0f0f0;font-family:sans-serif;"><div>Lade Dokument...</div></body></html>');
try {
const barcode = barcodes[virtualId];
if (!barcode) {
printWindow.close();
return;
}
const blob = await emailImportApi.printPreview(attachmentId, barcode);
const url = window.URL.createObjectURL(blob);
// Navigate the already open window to the PDF
printWindow.location.href = url;
// Some browsers allow triggering print() on the new window
// but it's inconsistent for PDFs. Most PDF viewers have their own print button.
// So we just leave it open for the user.
} catch (e) {
message.error('Fehler beim Generieren der Druckvorschau');
printWindow.close();
}
};
// --- Step 1: Zuordnung Render ---
const renderStep1 = () => {
const columns: ColumnsType<any> = [
{
title: 'Dateiname',
dataIndex: 'fileName',
key: 'fileName',
render: (text, record) => (
<Space>
<Text delete={record.isDuplicate} type={record.isDuplicate ? 'secondary' : undefined}>{text}</Text>
{attachments.find(a => a.Id === record.attachmentId)?.Erechnung && (
<span style={{ color: 'green', border: '1px solid green', padding: '0 4px', borderRadius: '4px' }}>eRechnung</span>
)}
{record.isDuplicate && (
<Tag color="orange" icon={<WarningOutlined />}>Bereits vorhanden</Tag>
)}
</Space>
)
},
{
title: 'Aktion',
key: 'action',
render: (_, record) => {
const hasOtherMain = importData.some(item => item.type === 'MAIN' && item.virtualId !== record.virtualId);
const showAttachmentOption = importData.length > 1 && hasOtherMain;
return (
<Radio.Group
value={record.type}
disabled={record.isDuplicate}
onChange={e => updateImportData(record.virtualId, 'type', e.target.value)}
>
<Radio value="MAIN">Importieren</Radio>
<Radio value="IGNORE">Ignorieren</Radio>
{showAttachmentOption && <Radio value="ATTACHMENT">Als Anlage</Radio>}
</Radio.Group>
);
}
},
{
title: 'Hauptdokument',
key: 'parent',
render: (_, record) => {
if (record.type === 'ATTACHMENT') {
const mainItems = importData.filter(i => i.type === 'MAIN' && i.virtualId !== record.virtualId);
return (
<Select
style={{ width: 250 }}
showSearch
placeholder="Hauptdokument auswählen..."
optionFilterProp="children"
value={record.parentVirtualId}
disabled={record.isDuplicate}
onChange={(val) => updateImportData(record.virtualId, 'parentVirtualId', val)}
>
{mainItems.map(item => <Select.Option key={item.virtualId} value={item.virtualId}>{item.fileName}</Select.Option>)}
</Select>
);
}
return null;
}
}
];
return (
<div>
<div style={{ marginBottom: 16 }}>
<Text strong>Absender: </Text> <Text>{email.From}</Text>
<div style={{ marginTop: 8 }}>
<Text strong>Korrespondent (Paperless): </Text>
<Select
style={{ width: 300 }}
showSearch
allowClear
placeholder="Korrespondent auswählen"
value={suggestedCorrespondentId}
onChange={(val) => {
setSuggestedCorrespondentId(val);
setImportData(prev => prev.map(i => ({ ...i, paperlessCorrespondentId: val })));
}}
>
{correspondents.map(c => <Select.Option key={c.id} value={c.id}>{c.name}</Select.Option>)}
</Select>
</div>
</div>
<Table
columns={columns}
dataSource={importData}
rowKey="virtualId"
pagination={false}
expandable={{
expandedRowKeys: expandedRows,
onExpand: (expanded, record) => {
setExpandedRows(expanded ? [record.virtualId] : []);
},
expandedRowRender: record => {
const originalAtt = attachments.find(a => a.Id === record.attachmentId);
if (!originalAtt) return null;
return (
<PdfSplitViewer
attachmentId={record.attachmentId}
pageCount={originalAtt.PageCount || 0}
startPage={record.pages?.start}
endPage={record.pages?.end === 999 ? undefined : record.pages?.end}
onSplit={(page) => handleSplit(record.virtualId, page)}
disabled={originalAtt.Erechnung}
/>
);
}
}}
/>
</div>
);
};
// --- Step 2: Bearbeitung Render ---
const renderStep2 = () => {
const toProcess = importData.filter(i => i.type !== 'IGNORE');
if (toProcess.length === 0) return <Text>Keine Dokumente zum Importieren ausgewählt.</Text>;
return (
<div>
{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}>
{/* Eingangsdatum */}
<div style={{ marginBottom: 16 }}>
<Text style={{ display: 'block', marginBottom: 4 }}>Eingangsdatum:</Text>
<DatePicker
value={eingangsdaten[item.virtualId]}
onChange={(date) => {
if (date) {
setEingangsdaten(prev => ({ ...prev, [item.virtualId]: date }));
setBarcodes(prev => ({
...prev,
[item.virtualId]: { ...prev[item.virtualId], datum: date.format('YYYY-MM-DD'), jahr: date.format('YYYY') }
}));
}
}}
style={{ width: '100%' }}
format="DD.MM.YYYY"
/>
</div>
{/* Belegnummer */}
<Text strong style={{ display: 'block', marginBottom: 4 }}>Belegnummer</Text>
<Radio.Group
value={belegnummerMode[item.virtualId] || 'neu'}
onChange={e => {
const mode = e.target.value;
setBelegnummerMode(prev => ({ ...prev, [item.virtualId]: mode }));
if (mode === 'manuell') {
const jahr = (eingangsdaten[item.virtualId] || dayjs(email.Date)).format('YYYY');
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], jahr, isNeu: false } }));
if (!belegnummern[item.virtualId] || belegnummerMode[item.virtualId] === 'neu') {
setBelegnummern(prev => ({ ...prev, [item.virtualId]: '000000' }));
}
} else {
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], isNeu: true } }));
}
}}
style={{ marginBottom: 12 }}
>
<Radio value="neu">Neu</Radio>
<Radio value="manuell">Manuell</Radio>
</Radio.Group>
{(belegnummerMode[item.virtualId] || 'neu') === 'manuell' && (
<div>
<div style={{ marginBottom: 8 }}>
<Text style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>Jahr (4-stellig):</Text>
<Input
value={barcodes[item.virtualId]?.jahr || ''}
maxLength={4}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').slice(0, 4);
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], jahr: val } }));
}}
style={{ width: '100%' }}
placeholder="2026"
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ display: 'block', marginBottom: 4, fontSize: 12 }}>Nummer (6-stellig):</Text>
<Input
value={belegnummern[item.virtualId] || ''}
maxLength={6}
onChange={e => {
const val = e.target.value.replace(/\D/g, '').slice(0, 6);
setBelegnummern(prev => ({ ...prev, [item.virtualId]: val }));
setBarcodes(prev => ({ ...prev, [item.virtualId]: { ...prev[item.virtualId], nummer: val } }));
}}
style={{ width: '100%' }}
placeholder="000000"
/>
</div>
</div>
)}
{(belegnummerMode[item.virtualId] || 'neu') === 'neu' && belegnummern[item.virtualId] && (
<div style={{ marginTop: 4 }}>
<Text style={{ fontSize: 13 }}>Reserviert: <Text strong>{belegnummern[item.virtualId]}</Text></Text>
</div>
)}
<div style={{ marginTop: 12 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
Position: {barcodes[item.virtualId]?.x || 0} × {barcodes[item.virtualId]?.y || 0} mm
</Text>
</div>
</Col>
<Col span={16}>
<BarcodePositioner
attachmentId={item.attachmentId}
startPage={item.pages?.start}
belegnummer={belegnummern[item.virtualId] || ''}
isNeu={(belegnummerMode[item.virtualId] || 'neu') === 'neu'}
datum={barcodes[item.virtualId]?.datum}
jahr={barcodes[item.virtualId]?.jahr}
position={{ x: barcodes[item.virtualId]?.x || 0, y: barcodes[item.virtualId]?.y || 0 }}
onPositionChange={(pos) => setBarcodes(prev => ({
...prev,
[item.virtualId]: { ...prev[item.virtualId], x: pos.x, y: pos.y }
}))}
/>
</Col>
</Row>
</div>
))}
</div>
);
};
// --- Step 3: Abschluss Render ---
const renderStep3 = () => {
if (importSuccess) {
return (
<Result
status="success"
title="Import Erfolgreich!"
subTitle="Die Dokumente wurden erfolgreich nach Paperless importiert und die Belegnummern verbucht."
extra={[
<Button type="primary" key="close" onClick={onClose}>
Schließen
</Button>,
...importData.filter(i => i.type !== 'IGNORE').map(item => (
<Button key={`print-${item.virtualId}`} icon={<PrinterOutlined />} onClick={() => printDocument(item.virtualId, item.attachmentId)}>
Drucken: {item.fileName}
</Button>
))
]}
/>
);
}
// Summary view before import
const mainDocs = importData.filter(i => i.type === 'MAIN');
const attachmentsToImport = importData.filter(i => i.type === 'ATTACHMENT');
return (
<div style={{ padding: '0 24px' }}>
<Title level={4} style={{ marginBottom: 24 }}>Zusammenfassung des Imports</Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 24 }}>
Bitte überprüfe die folgende Struktur. Mit Klick auf "Import Ausführen" werden die Belegnummern reserviert und die Dokumente hochgeladen.
</Text>
{mainDocs.length === 0 && <Alert type="warning" message="Keine Hauptdokumente zum Importieren ausgewählt." />}
{mainDocs.map(main => {
const mainAttachments = attachmentsToImport.filter(a => a.parentVirtualId === main.virtualId);
const mode = belegnummerMode[main.virtualId] || 'neu';
const num = belegnummern[main.virtualId] || '000000';
const yr = barcodes[main.virtualId]?.jahr || dayjs(email.Date).format('YYYY');
return (
<div key={main.virtualId} style={{ marginBottom: 24 }}>
<Card size="small" style={{ borderLeft: '4px solid #1890ff' }}>
<Row align="middle">
<Col span={16}>
<Space>
<FileTextOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div>
<Text strong style={{ fontSize: 16 }}>{main.fileName}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>Hauptdokument</Text>
</div>
</Space>
</Col>
<Col span={8} style={{ textAlign: 'right' }}>
<Tag color="blue" style={{ fontSize: 14, padding: '4px 12px' }}>
{mode === 'neu' ? `Belegnr.: ${yr}-${num}` : `Belegnr.: ${yr}-${num}`}
</Tag>
</Col>
</Row>
{mainAttachments.length > 0 && (
<div style={{ marginTop: 12, paddingLeft: 32 }}>
{mainAttachments.map(att => (
<div key={att.virtualId} style={{ marginBottom: 8, padding: '8px 12px', background: '#fafafa', borderRadius: 4, display: 'flex', alignItems: 'center' }}>
<ArrowRightOutlined style={{ marginRight: 12, color: '#8c8c8c' }} />
<PaperClipOutlined style={{ marginRight: 8 }} />
<Text style={{ flex: 1 }}>{att.fileName}</Text>
<Tag>Anlage</Tag>
</div>
))}
</div>
)}
</Card>
</div>
);
})}
{/* Orphans (Attachments without parent or parents ignored) */}
{attachmentsToImport.filter(a => !mainDocs.find(m => m.virtualId === a.parentVirtualId)).map(orphan => (
<div key={orphan.virtualId} style={{ marginBottom: 12 }}>
<Alert
type="error"
message={`Anlage ohne Hauptdokument: ${orphan.fileName}`}
description="Bitte gehe zurück und ordne diese Anlage einem Hauptdokument zu oder ignoriere sie."
/>
</div>
))}
</div>
);
};
return (
<Modal
title="Paperless Import-Wizard"
open={visible}
onCancel={onClose}
width={1000}
footer={
importSuccess ? null : (
<Space>
{currentStep > 0 && <Button onClick={handleBack}>Zurück</Button>}
{currentStep < 2 && <Button type="primary" onClick={nextStep} loading={loading}>Weiter</Button>}
{currentStep === 2 && <Button type="primary" onClick={executeImport} loading={loading}>Import Ausführen</Button>}
</Space>
)
}
>
<Steps
current={currentStep}
items={[
{ title: 'Zuordnung', description: 'Dateien auswählen' },
{ title: 'Bearbeitung', description: 'Barcode & Splitting' },
{ title: 'Abschluss', description: 'Import & Druck' },
]}
style={{ marginBottom: 24 }}
/>
<Spin spinning={loading}>
<div style={{ minHeight: 300 }}>
{currentStep === 0 && renderStep1()}
{currentStep === 1 && renderStep2()}
{currentStep === 2 && renderStep3()}
</div>
</Spin>
</Modal>
);
}
@@ -0,0 +1,103 @@
import { useState, useEffect } from 'react';
import { Button, Tooltip, Spin } from 'antd';
import { ScissorOutlined } from '@ant-design/icons';
import { getEnv } from '../utils/env';
import { getAccessToken } from '../auth/oidc';
interface PdfSplitViewerProps {
attachmentId: number;
pageCount: number;
startPage?: number;
endPage?: number;
onSplit: (pageIndex: number) => void;
disabled: boolean;
}
export default function PdfSplitViewer({ attachmentId, pageCount, startPage, endPage, onSplit, disabled }: PdfSplitViewerProps) {
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
getAccessToken().then(t => setToken(t));
}, []);
if (!token) return <Spin />;
if (pageCount === 0) {
return (
<div style={{ padding: 16, background: '#fafafa', borderRadius: 8, textAlign: 'center' }}>
<p>Vorschau nicht verfügbar (Dokument wurde vor dem Update geladen).</p>
</div>
);
}
const actualStart = startPage || 1;
const actualEnd = endPage || pageCount;
const pagesToRender = [];
for (let i = actualStart; i <= actualEnd; i++) {
pagesToRender.push(i);
}
const getImageUrl = (page: number) => {
// We add the token as a query parameter because <img> tags don't support Authorization headers easily.
// However, it's more secure to fetch the blob and create an object URL, or rely on cookie auth.
// Let's fetch the blob and use object URLs to pass the bearer token securely.
return `${getEnv('VITE_API_URL')}/api/email-import/attachments/${attachmentId}/pages/${page}/thumb`;
};
return (
<div style={{ display: 'flex', overflowX: 'auto', padding: '16px 8px', background: '#fafafa', borderRadius: 8 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{pagesToRender.map((pageNum) => (
<div key={pageNum} style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ border: '1px solid #d9d9d9', boxShadow: '0 2px 8px rgba(0,0,0,0.1)', background: 'white', height: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<ImageWithAuth url={getImageUrl(pageNum)} token={token} />
</div>
{!disabled && pageNum < actualEnd && (
<div style={{ width: 24, display: 'flex', justifyContent: 'center', zIndex: 10, marginLeft: 8 }}>
<Tooltip title="Hier trennen">
<Button
type="primary"
danger
shape="circle"
size="small"
icon={<ScissorOutlined />}
onClick={() => onSplit(pageNum)}
/>
</Tooltip>
</div>
)}
</div>
))}
</div>
</div>
);
}
function ImageWithAuth({ url, token }: { url: string; token: string }) {
const [imgSrc, setImgSrc] = useState<string | null>(null);
useEffect(() => {
let objectUrl: string;
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => {
if (!res.ok) throw new Error('Network response was not ok');
return res.blob();
})
.then(blob => {
objectUrl = URL.createObjectURL(blob);
setImgSrc(objectUrl);
})
.catch(err => {
console.error('Error loading image', err);
});
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [url, token]);
if (!imgSrc) return <Spin style={{ margin: '0 20px' }} />;
return <img src={imgSrc} alt="PDF Page" style={{ height: '100%', objectFit: 'contain' }} />;
}
+35
View File
@@ -0,0 +1,35 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* Light mode body background — extra hell für minimale Spiegelung */
[data-theme="light"] body {
background: #f8f9fc;
}
[data-theme="dark"] body {
background: #141414;
}
/* Fix for font size in Ant Design components */
.ant-select,
.ant-select-selection-search-input,
.ant-select-item,
.ant-input,
.ant-input-affix-wrapper,
.ant-picker,
.ant-picker-input > input {
font-size: 14px !important;
}
@@ -0,0 +1,240 @@
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 {
InboxOutlined,
FileTextOutlined,
MailOutlined,
SettingOutlined,
LogoutOutlined,
UserOutlined,
EditOutlined,
SunOutlined,
MoonOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext';
import { Permission } from '../auth/permissions';
import { statsApi, type StatsCounts } from '../api/stats';
const { Sider, Content } = Layout;
const { Text } = Typography;
type MenuItemDef = {
key: string;
icon: React.ReactNode;
label: string;
permission?: Permission;
countKey?: keyof StatsCounts;
};
const allMenuItems: MenuItemDef[] = [
{ key: '/dashboard', icon: <AppstoreOutlined />, label: 'Dashboard' },
{ key: '/inbox', icon: <InboxOutlined />, label: 'Eingangsbox', permission: Permission.VIEW_SCANNER, countKey: 'inbox' },
{ key: '/posteingang', icon: <FileTextOutlined />, label: 'Posteingang', permission: Permission.VIEW_INBOX, countKey: 'posteingang' },
{ key: '/manuell', icon: <EditOutlined />, label: 'Manuell bearbeiten', permission: Permission.PROCESS_MANUALLY, countKey: 'manuell' },
{ key: '/mailpostfach', icon: <MailOutlined />, label: 'Mailpostfach', permission: Permission.VIEW_MAIL, countKey: 'mailpostfach' },
{ key: '/settings', icon: <SettingOutlined />, label: 'Einstellungen', permission: Permission.MANAGE_SETTINGS },
];
export default function AppLayout() {
const [collapsed, setCollapsed] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const { user, logout, hasPermission, isAuthenticated } = useAuth();
const { token: themeToken } = theme.useToken();
const { isDark, toggleTheme } = useTheme();
const [counts, setCounts] = useState<StatsCounts | null>(null);
useEffect(() => {
if (!isAuthenticated) return;
const fetchCounts = async () => {
try {
const data = await statsApi.getCounts();
setCounts(data);
} catch (err) {
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
const menuItems = allMenuItems
.filter((item) => !item.permission || hasPermission(item.permission))
.map((item) => ({
...item,
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]}
overflowCount={99}
size="small"
color={isDark ? themeToken.colorPrimary : '#1677ff'}
style={{ transform: 'translateY(-2px)' }}
/>
)}
</div>
),
}));
const selectedKey = menuItems
.map((item) => item.key)
.filter((key) => location.pathname === key || location.pathname.startsWith(key + '/'))
.sort((a, b) => b.length - a.length)[0] || (menuItems[0]?.key ?? '/inbox');
const siderStyle = isDark
? {}
: {
background: '#f0f2f7',
borderRight: '1px solid #e2e4ea',
};
const logoColor = isDark ? '#fff' : '#1a1a2e';
const subtleColor = isDark ? '#ffffffa6' : '#4a4a6a';
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 */}
<div
onClick={() => setCollapsed(!collapsed)}
style={{
height: 56,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
userSelect: 'none',
borderBottom: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
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>
</div>
{/* Navigation Menu */}
<Menu
theme={isDark ? 'dark' : 'light'}
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => navigate(key)}
style={isDark ? { flex: 1 } : { background: 'transparent', flex: 1 }}
/>
{/* Bottom Section: User + Theme Toggle */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
borderTop: `1px solid ${isDark ? 'rgba(255,255,255,0.08)' : '#e2e4ea'}`,
padding: collapsed ? '12px 0' : '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 4,
transition: 'padding 0.2s',
}}
>
{/* Theme Toggle */}
<Tooltip title={isDark ? 'Light Mode' : 'Dark Mode'} placement="right">
<div
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',
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')}
>
{isDark ? <SunOutlined style={{ fontSize: 16 }} /> : <MoonOutlined style={{ fontSize: 16 }} />}
{!collapsed && <Text style={{ color: subtleColor, fontSize: 13 }}>{isDark ? 'Light Mode' : 'Dark Mode'}</Text>}
</div>
</Tooltip>
{/* User Menu */}
<Dropdown
menu={{
items: [
{
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 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,252 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Typography, Row, Col, Card, Badge, Result, Button, Spin, theme } from 'antd';
import {
InboxOutlined,
FileTextOutlined,
MailOutlined,
EditOutlined,
ArrowRightOutlined,
} from '@ant-design/icons';
import type { ReactNode } from 'react';
import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext';
import { Permission } from '../auth/permissions';
import { statsApi, type StatsCounts } from '../api/stats';
const { Title, Text, Paragraph } = Typography;
interface DashboardTile {
key: keyof StatsCounts;
path: string;
title: string;
description: string;
icon: ReactNode;
permission: Permission;
accent: string;
accentSoft: string;
accentSoftDark: string;
}
const tiles: DashboardTile[] = [
{
key: 'inbox',
path: '/inbox',
title: 'Eingangsbox',
description: 'Neu eingegangene Scans, Uploads und E-Mail-Anhänge prüfen.',
icon: <InboxOutlined />,
permission: Permission.VIEW_SCANNER,
accent: '#1677ff',
accentSoft: '#e6f0ff',
accentSoftDark: 'rgba(22, 119, 255, 0.16)',
},
{
key: 'posteingang',
path: '/posteingang',
title: 'Posteingang',
description: 'Verarbeitete Dokumente sichten und an Paperless übergeben.',
icon: <FileTextOutlined />,
permission: Permission.VIEW_INBOX,
accent: '#13c2c2',
accentSoft: '#e6fffb',
accentSoftDark: 'rgba(19, 194, 194, 0.16)',
},
{
key: 'manuell',
path: '/manuell',
title: 'Manuell bearbeiten',
description: 'Dokumente mit fehlender Erkennung manuell ergänzen.',
icon: <EditOutlined />,
permission: Permission.PROCESS_MANUALLY,
accent: '#fa8c16',
accentSoft: '#fff7e6',
accentSoftDark: 'rgba(250, 140, 22, 0.18)',
},
{
key: 'mailpostfach',
path: '/mailpostfach',
title: 'Mailpostfach',
description: 'Eingehende E-Mails mit Anhängen durchsuchen und zuordnen.',
icon: <MailOutlined />,
permission: Permission.VIEW_MAIL,
accent: '#722ed1',
accentSoft: '#f9f0ff',
accentSoftDark: 'rgba(114, 46, 209, 0.18)',
},
];
export default function DashboardPage() {
const navigate = useNavigate();
const { user, hasPermission, isAuthenticated } = useAuth();
const { isDark } = useTheme();
const { token } = theme.useToken();
const [counts, setCounts] = useState<StatsCounts | null>(null);
const [loading, setLoading] = useState(true);
const visibleTiles = tiles.filter((tile) => hasPermission(tile.permission));
useEffect(() => {
if (!isAuthenticated) return;
let cancelled = false;
const fetchCounts = async () => {
try {
const data = await statsApi.getCounts();
if (!cancelled) setCounts(data);
} catch (err) {
console.error('Fehler beim Abrufen der Zählerstände:', err);
} finally {
if (!cancelled) setLoading(false);
}
};
fetchCounts();
const interval = setInterval(fetchCounts, 30000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [isAuthenticated]);
const userName =
(user?.profile?.given_name as string | undefined) ||
(user?.profile?.name as string | undefined) ||
'Willkommen';
if (visibleTiles.length === 0) {
return (
<Result
status="info"
title="Keine Abschnitte freigegeben"
subTitle="Für Ihr Konto sind aktuell keine Bereiche freigeschaltet. Bitte wenden Sie sich an einen Administrator."
/>
);
}
const totalPending = visibleTiles.reduce(
(sum, tile) => sum + (counts?.[tile.key] ?? 0),
0,
);
return (
<div>
<div style={{ marginBottom: 32 }}>
<Title level={2} style={{ marginBottom: 4 }}>
Hallo, {userName}
</Title>
<Text type="secondary" style={{ fontSize: 15 }}>
{loading
? 'Daten werden geladen …'
: totalPending > 0
? `Sie haben ${totalPending} offene Vorgänge in Ihren Bereichen.`
: 'Alle Bereiche sind auf dem aktuellen Stand.'}
</Text>
</div>
{loading && !counts ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 64 }}>
<Spin size="large" />
</div>
) : (
<Row gutter={[24, 24]}>
{visibleTiles.map((tile) => {
const count = counts?.[tile.key] ?? 0;
const iconBg = isDark ? tile.accentSoftDark : tile.accentSoft;
return (
<Col key={tile.key} xs={24} sm={12} lg={8} xxl={6}>
<Card
hoverable
onClick={() => navigate(tile.path)}
styles={{
body: {
padding: 24,
height: '100%',
display: 'flex',
flexDirection: 'column',
},
}}
style={{
height: '100%',
borderRadius: 12,
borderTop: `3px solid ${tile.accent}`,
transition: 'transform 0.15s ease, box-shadow 0.15s ease',
}}
>
<div
style={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<div
style={{
width: 48,
height: 48,
borderRadius: 12,
background: iconBg,
color: tile.accent,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 22,
}}
>
{tile.icon}
</div>
{count > 0 && (
<Badge
count={count}
overflowCount={999}
style={{
backgroundColor: tile.accent,
boxShadow: 'none',
}}
/>
)}
</div>
<Title level={4} style={{ marginTop: 0, marginBottom: 8 }}>
{tile.title}
</Title>
<Paragraph
type="secondary"
style={{ marginBottom: 16, flex: 1 }}
>
{tile.description}
</Paragraph>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingTop: 12,
borderTop: `1px solid ${token.colorBorderSecondary}`,
}}
>
<Text style={{ fontSize: 13, color: token.colorTextSecondary }}>
{count > 0
? `${count} offen`
: 'Keine offenen Vorgänge'}
</Text>
<Button
type="link"
size="small"
style={{ padding: 0, color: tile.accent }}
>
Öffnen <ArrowRightOutlined />
</Button>
</div>
</Card>
</Col>
);
})}
</Row>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Button,
Card,
Input,
Popconfirm,
Popover,
Space,
Spin,
Table,
Tag,
Tooltip,
Typography,
message,
} from 'antd';
import {
DeleteOutlined,
EyeOutlined,
FolderOpenOutlined,
QrcodeOutlined,
ReloadOutlined,
ScanOutlined,
SearchOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { inboxApi, type InboxBarcode, type InboxFile } from '../api/inbox';
const { Title } = Typography;
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function renderBarcodes(barcodes: InboxBarcode[]): ReactNode {
if (!barcodes || barcodes.length === 0) {
return <Typography.Text type="secondary"></Typography.Text>;
}
return (
<Space size={[4, 4]} wrap>
{barcodes.map((b, idx) => {
const label = b.templateName ?? b.value;
const color = b.templateName ? 'green' : 'default';
return (
<Tooltip
key={`${b.page}-${idx}`}
title={
<div>
<div>Seite {b.page}</div>
<div style={{ fontFamily: 'monospace' }}>{b.value}</div>
{!b.templateName && <div>Keine passende Vorlage</div>}
</div>
}
>
<Tag icon={<QrcodeOutlined />} color={color}>
S.{b.page}: {label}
</Tag>
</Tooltip>
);
})}
</Space>
);
}
function DocumentPreviewPopover({ record, children }: { record: InboxFile; children: ReactNode }) {
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const handleOpenChange = async (open: boolean) => {
if (open && !blobUrl && !loading) {
setLoading(true);
try {
const blob = await inboxApi.thumbnailBlob(record.id, 1);
setBlobUrl(URL.createObjectURL(blob));
} catch {
// error handling handled implicitly
} finally {
setLoading(false);
}
}
};
useEffect(() => {
return () => {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [blobUrl]);
const content = (
<div style={{ width: 250, minHeight: 150, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{loading ? (
<Spin />
) : blobUrl ? (
<img src={blobUrl} alt="Vorschau" style={{ maxWidth: '100%', maxHeight: 350, objectFit: 'contain' }} />
) : (
<Typography.Text type="secondary">Vorschau nicht verfügbar</Typography.Text>
)}
</div>
);
return (
<Popover content={content} title="Vorschau (Seite 1)" onOpenChange={handleOpenChange} placement="right" mouseEnterDelay={0.5}>
<span style={{ cursor: 'pointer', textDecoration: 'underline dashed #ccc', display: 'inline-block' }}>{children}</span>
</Popover>
);
}
export default function InboxPage() {
const navigate = useNavigate();
const [files, setFiles] = useState<InboxFile[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [rescanning, setRescanning] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const data = await inboxApi.list();
setFiles(data);
} catch {
message.error('Dateien konnten nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleRescan = async () => {
setRescanning(true);
const hide = message.loading('Rescan läuft das kann je nach Anzahl der Dokumente dauern …', 0);
try {
const { scanned, failed } = await inboxApi.rescan();
hide();
if (failed > 0) {
message.warning(`Rescan abgeschlossen: ${scanned} ok, ${failed} fehlgeschlagen`);
} else {
message.success(`Rescan abgeschlossen: ${scanned} Dokument(e) neu gescannt`);
}
await load();
} catch {
hide();
message.error('Rescan fehlgeschlagen');
} finally {
setRescanning(false);
}
};
const handleDelete = async (id: string) => {
try {
await inboxApi.remove(id);
message.success('Dokument gelöscht');
await load();
} catch {
message.error('Löschen fehlgeschlagen');
}
};
const filtered = files.filter((f) =>
search ? f.name.toLowerCase().includes(search.toLowerCase()) : true,
);
const columns: ColumnsType<InboxFile> = [
{
title: 'Dateiname',
dataIndex: 'name',
key: 'name',
sorter: (a, b) => a.name.localeCompare(b.name),
render: (name: string, record) => (
<DocumentPreviewPopover record={record}>
<Typography.Text>{name}</Typography.Text>
</DocumentPreviewPopover>
),
},
{
title: 'Quelle',
dataIndex: 'source',
key: 'source',
width: 160,
filters: [
{ text: 'Gemeinsam', value: 'all' },
{ text: 'Persönlich', value: 'user' },
],
onFilter: (value, record) => record.source === value,
render: (src: InboxFile['source']) =>
src === 'user' ? (
<Tag icon={<UserOutlined />} color="purple">
Persönlich
</Tag>
) : (
<Tag icon={<FolderOpenOutlined />} color="blue">
Gemeinsam
</Tag>
),
},
{
title: 'QR-Code / Vorlage',
key: 'barcodes',
width: 260,
render: (_, record) => renderBarcodes(record.barcodes),
},
{
title: 'Seiten',
dataIndex: 'pageCount',
key: 'pageCount',
width: 100,
sorter: (a, b) => a.pageCount - b.pageCount,
render: (n: number) => (n > 0 ? n : <Typography.Text type="secondary"></Typography.Text>),
},
{
title: 'Empfangen',
dataIndex: 'createdAt',
key: 'createdAt',
width: 180,
defaultSortOrder: 'descend',
sorter: (a, b) => a.createdAt.localeCompare(b.createdAt),
render: (iso: string) => formatDate(iso),
},
{
title: 'Aktionen',
key: 'actions',
width: 140,
render: (_, record) => (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<Tooltip title="Vorschau öffnen">
<Button
type="link"
icon={<EyeOutlined />}
onClick={() => navigate(`/inbox/${encodeURIComponent(record.id)}`)}
>
Vorschau
</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>
</div>
),
},
];
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<div>
<Title level={3} style={{ margin: 0 }}>
Eingangsbox
</Title>
<Typography.Text type="secondary">
Dateien aus <code>/mnt/scans/all</code> und Ihrem persönlichen
Scan-Ordner.
</Typography.Text>
</div>
<Space>
<Input
prefix={<SearchOutlined />}
placeholder="Suchen …"
style={{ width: 260 }}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
/>
<Button icon={<ReloadOutlined />} onClick={load}>
Aktualisieren
</Button>
<Popconfirm
title="Alle Dokumente neu scannen?"
description="Alle PDFs in der Eingangsbox werden erneut auf QR-Codes geprüft. Das kann je nach Anzahl der Dokumente einige Zeit dauern."
okText="Rescan starten"
cancelText="Abbrechen"
onConfirm={handleRescan}
>
<Button icon={<ScanOutlined />} loading={rescanning}>
Rescan
</Button>
</Popconfirm>
</Space>
</div>
<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>
);
}
@@ -0,0 +1,44 @@
import { Button, Typography, Space } from 'antd';
import { LoginOutlined } from '@ant-design/icons';
import { useAuth } from '../auth/AuthContext';
import { useTheme } from '../theme/ThemeContext';
const { Title, Paragraph } = Typography;
export default function LoginPage() {
const { login } = useAuth();
const { isDark } = useTheme();
const backgroundStyle = isDark
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: 'linear-gradient(135deg, #e8ecf8 0%, #f0f4ff 50%, #e6f0ff 100%)';
const titleColor = isDark ? '#fff' : '#1a1a2e';
const subtitleColor = isDark ? 'rgba(255,255,255,0.8)' : '#4a4a6a';
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: backgroundStyle,
}}>
<Space direction="vertical" align="center" style={{ textAlign: 'center' }}>
<Title style={{ color: titleColor, margin: 0 }}>Paperless Manager</Title>
<Paragraph style={{ color: subtitleColor, fontSize: 16 }}>
Dokumenten-Middleware für Paperless-ngx
</Paragraph>
<Button
type="primary"
size="large"
icon={<LoginOutlined />}
onClick={login}
style={{ marginTop: 24 }}
>
Mit Authentik anmelden
</Button>
</Space>
</div>
);
}
@@ -0,0 +1,259 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Button, Space, Spin, Tag, Typography, Table, message, Empty, Popconfirm
} from 'antd';
import { ArrowLeftOutlined, FileTextOutlined, CloseCircleOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
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';
const { Title, Text } = Typography;
export default function MailDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [email, setEmail] = useState<EmailItem | null>(null);
const [attachments, setAttachments] = useState<EmailAttachment[]>([]);
const [selected, setSelected] = useState<EmailAttachment | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [previewLoading, setPreviewLoading] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
useEffect(() => {
if (!id) return;
const emailId = parseInt(id, 10);
Promise.all([emailsApi.get(emailId), emailsApi.listAttachments(emailId)])
.then(([mail, att]) => {
setEmail(mail);
setAttachments(att);
if (att.length > 0) setSelected(att[0]);
})
.catch(() => message.error('E-Mail nicht gefunden'))
.finally(() => setLoading(false));
}, [id]);
const handleIgnore = async () => {
if (!email) return;
try {
await emailsApi.updateStatus(email.Id, 3);
message.success('E-Mail als ignoriert markiert');
navigate('/mailpostfach');
} catch (err) {
message.error('Fehler beim Markieren der E-Mail');
}
};
useEffect(() => {
if (!selected) {
setPreviewUrl(null);
return;
}
setPreviewLoading(true);
let objectUrl: string | null = null;
emailsApi.getAttachmentContent(selected.Id)
.then((blob) => {
objectUrl = URL.createObjectURL(blob);
setPreviewUrl(objectUrl);
})
.catch(() => message.error('Vorschau konnte nicht geladen werden'))
.finally(() => setPreviewLoading(false));
return () => {
if (objectUrl) URL.revokeObjectURL(objectUrl);
};
}, [selected]);
if (loading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!email) return <Text>E-Mail nicht gefunden.</Text>;
const isHtml = /<[a-z][\s\S]*>/i.test(email.Body);
const hasErechnung = attachments.some((a) => a.Erechnung);
const columns: ColumnsType<EmailAttachment> = [
{
title: 'Dateiname',
dataIndex: 'FileName',
key: 'FileName',
ellipsis: true,
},
{
title: 'Typ',
dataIndex: 'ContentType',
key: 'ContentType',
width: 160,
ellipsis: true,
},
{
title: 'eRechnung',
dataIndex: 'Erechnung',
key: 'Erechnung',
width: 100,
align: 'center',
render: (v: boolean) => (v ? <Tag color="green">Ja</Tag> : <Tag>Nein</Tag>),
},
{
title: 'Paperless ID',
dataIndex: 'PaperlessDocumentIds',
key: 'PaperlessDocumentIds',
width: 130,
render: (ids: Record<string, number> | null) => {
if (!ids) return null;
const entries = Object.entries(ids);
if (entries.length === 0) return null;
return (
<Space size={[0, 4]} wrap>
{entries.map(([, id]) => (
<Tag
color="blue"
key={id}
style={{ cursor: 'pointer' }}
onClick={(e) => {
e.stopPropagation();
const paperlessUrl = getEnv('VITE_PAPERLESS_URL');
if (paperlessUrl) {
window.open(`${paperlessUrl}/documents/${id}`, '_blank');
}
}}
>
{id}
</Tag>
))}
</Space>
);
}
},
];
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/mailpostfach')}>
Zurück
</Button>
<Title level={3} style={{ margin: 0 }}>{email.Subject}</Title>
{hasErechnung && <Tag color="green">eRechnung</Tag>}
</Space>
<Space>
<Popconfirm
title="E-Mail ignorieren"
description="Möchten Sie diese E-Mail wirklich als ignoriert markieren?"
onConfirm={handleIgnore}
okText="Ja"
cancelText="Nein"
placement="bottomRight"
>
<Button danger icon={<CloseCircleOutlined />}>
Als ignoriert markieren
</Button>
</Popconfirm>
<Button
type="primary"
icon={<FileTextOutlined />}
onClick={async () => {
if (!email) return;
const hide = message.loading('Prüfe Vorschaubilder...', 0);
try {
await emailImportApi.ensurePreviews(email.Id);
// Re-fetch attachments to get updated PageCount
const att = await emailsApi.listAttachments(email.Id);
setAttachments(att);
setWizardOpen(true);
} catch (err) {
message.error('Fehler bei der Vorschau-Prüfung');
} finally {
hide();
}
}}
>
Import-Wizard starten
</Button>
</Space>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '2fr 3fr', gap: 16, height: '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' } }}
>
<div style={{ marginBottom: 12 }}>
<div><Text type="secondary">Von:</Text> <Text>{email.SenderAddress}</Text></div>
<div><Text type="secondary">An:</Text> <Text>{email.RecipientAddress}</Text></div>
<div><Text type="secondary">Datum:</Text> <Text>{dayjs(email.Date).format('DD.MM.YYYY HH:mm')}</Text></div>
</div>
{isHtml ? (
<iframe
title="E-Mail Body"
srcDoc={email.Body}
sandbox=""
style={{ width: '100%', minHeight: '400px', flex: 1, border: '1px solid #303030', borderRadius: 4, background: '#fff' }}
/>
) : (
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit', margin: 0 }}>{email.Body}</pre>
)}
</Card>
{/* Rechte Seite: Anhänge + Vorschau */}
<Card size="small" styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column', height: 'calc(100vh - 200px)' } }}>
<div style={{ flex: '0 0 auto', borderBottom: '1px solid #303030', maxHeight: 240, overflow: 'auto' }}>
<Table<EmailAttachment>
columns={columns}
dataSource={attachments}
rowKey="Id"
size="small"
pagination={false}
rowClassName={(r) => (selected?.Id === r.Id ? 'ant-table-row-selected' : '')}
onRow={(record) => ({
onClick: () => setSelected(record),
style: { cursor: 'pointer' },
})}
locale={{ emptyText: <Empty description="Keine Anhänge" image={Empty.PRESENTED_IMAGE_SIMPLE} /> }}
/>
</div>
<div style={{ flex: 1, minHeight: 0, background: '#1a1a2e' }}>
{previewLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin />
</div>
) : previewUrl && selected ? (
selected.ContentType?.startsWith('image/') ? (
<img src={previewUrl} alt={selected.FileName} style={{ maxWidth: '100%', maxHeight: '100%', display: 'block', margin: '0 auto' }} />
) : (
<iframe
title={selected.FileName}
src={previewUrl}
style={{ width: '100%', height: '100%', border: 'none' }}
/>
)
) : (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', color: '#888' }}>
<Space direction="vertical" align="center">
<FileTextOutlined style={{ fontSize: 48 }} />
<Text type="secondary">Kein Anhang ausgewählt</Text>
</Space>
</div>
)}
</div>
</Card>
</div>
{wizardOpen && email && (
<MailImportWizard
visible={wizardOpen}
onClose={() => setWizardOpen(false)}
email={email}
attachments={attachments}
/>
)}
</div>
);
}
@@ -0,0 +1,204 @@
import { useEffect, useState, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Table, Card, Typography, Button, Space, Tag, message, Input, Select } from 'antd';
import { ReloadOutlined, DownloadOutlined, CheckCircleOutlined, SearchOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { emailsApi, type EmailItem } from '../api/emails';
import { useAuth } from '../auth/AuthContext';
import { Permission } from '../auth/permissions';
const { Title } = Typography;
export default function MailpostfachPage() {
const navigate = useNavigate();
const [emails, setEmails] = useState<EmailItem[]>([]);
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [checking, setChecking] = useState(false);
const [searchText, setSearchText] = useState(() => sessionStorage.getItem('mailSearch') || '');
const [statusFilter, setStatusFilter] = useState<number | 'all'>(() => {
const saved = sessionStorage.getItem('mailStatus');
return saved !== null && saved !== 'all' ? Number(saved) : 'all';
});
const { hasPermission } = useAuth();
const loadData = useCallback(async () => {
setLoading(true);
try {
const data = await emailsApi.list({ limit: 9999 });
setEmails(data);
} catch (err) {
message.error('E-Mails konnten nicht geladen werden');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useEffect(() => {
sessionStorage.setItem('mailSearch', searchText);
}, [searchText]);
useEffect(() => {
sessionStorage.setItem('mailStatus', String(statusFilter));
}, [statusFilter]);
const columns: ColumnsType<EmailItem> = [
{
title: 'Datum',
dataIndex: 'Date',
key: 'Date',
width: 160,
render: (d: string) => (d ? dayjs(d).format('DD.MM.YYYY HH:mm') : '-'),
sorter: (a, b) => new Date(a.Date).getTime() - new Date(b.Date).getTime(),
defaultSortOrder: 'descend',
},
{
title: 'Absender',
dataIndex: 'SenderAddress',
key: 'SenderAddress',
ellipsis: true,
},
{
title: 'Betreff',
dataIndex: 'Subject',
key: 'Subject',
ellipsis: true,
render: (subject: string, record) => {
const hasErechnung = record.Attachments?.some((a) => a.Erechnung);
return (
<Space size={8}>
<span>{subject}</span>
{hasErechnung && <Tag color="green">eRechnung</Tag>}
</Space>
);
},
},
{
title: 'Status',
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>;
},
filters: [
{ text: 'Neu', value: 0 },
{ text: 'Verarbeitet', value: 1 },
{ text: 'Fehler', value: 2 },
{ text: 'Ignoriert', value: 3 },
],
onFilter: (value, record) => record.Status === value,
},
];
const filteredEmails = emails.filter((email) => {
if (statusFilter !== 'all' && email.Status !== statusFilter) {
return false;
}
if (searchText) {
const lowerSearch = searchText.toLowerCase();
const matchSender = email.SenderAddress?.toLowerCase().includes(lowerSearch) || false;
const matchSubject = email.Subject?.toLowerCase().includes(lowerSearch) || false;
if (!matchSender && !matchSubject) return false;
}
return true;
});
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>Mailpostfach</Title>
<Space>
<Button
icon={<DownloadOutlined />}
onClick={async () => {
setFetching(true);
try {
const result = await emailsApi.triggerFetch();
message.success(result.message || 'E-Mails wurden abgerufen.');
await loadData();
} catch {
message.error('E-Mail-Abruf fehlgeschlagen.');
} finally {
setFetching(false);
}
}}
loading={fetching}
>
Abrufen
</Button>
<Button icon={<ReloadOutlined />} onClick={loadData} loading={loading}>
Aktualisieren
</Button>
{hasPermission(Permission.MANAGE_ALL) && (
<Button
icon={<CheckCircleOutlined />}
onClick={async () => {
setChecking(true);
try {
const result = await emailsApi.checkAttachments();
message.success(`${result.updatedCount} E-Mail(s) wurden als verarbeitet markiert.`);
if (result.updatedCount > 0) {
await loadData();
}
} catch {
message.error('Prüfung fehlgeschlagen.');
} finally {
setChecking(false);
}
}}
loading={checking}
>
Anhänge prüfen
</Button>
)}
</Space>
</div>
<Card>
<div style={{ marginBottom: 16, display: 'flex', gap: 16, flexWrap: 'wrap' }}>
<Input
placeholder="Suchen (Absender, Betreff)"
prefix={<SearchOutlined />}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 300 }}
allowClear
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
style={{ width: 200 }}
options={[
{ value: 'all', label: 'Alle Status' },
{ value: 0, label: 'Neu' },
{ value: 1, label: 'Verarbeitet' },
{ value: 2, label: 'Fehler' },
{ value: 3, label: 'Ignoriert' },
]}
/>
</div>
<Table<EmailItem>
columns={columns}
dataSource={filteredEmails}
loading={loading}
rowKey="Id"
pagination={{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `${t} E-Mails` }}
onRow={(record) => ({
onClick: () => navigate(`/mailpostfach/${record.Id}`),
style: { cursor: 'pointer' },
})}
/>
</Card>
</div>
);
}
@@ -0,0 +1,143 @@
import { useEffect, useState } from 'react';
import { Table, Popover, Image, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
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';
export default function ManuellBearbeitenPage() {
const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<PosteingangDocument | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const docs = await posteingangApi.getManuellList();
setData(docs || []);
} catch (e) {
message.error("Dokumente konnten nicht geladen werden.");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// Refresh interval every 30 seconds
const interval = setInterval(() => {
fetchData();
}, 30000);
return () => clearInterval(interval);
}, []);
const handleEdit = (doc: PosteingangDocument) => {
setSelectedDoc(doc);
setEditModalOpen(true);
};
const handleCloseModal = (openNext?: boolean) => {
setEditModalOpen(false);
if (openNext && data.length > 0) {
const currentIndex = data.findIndex(d => d.id === selectedDoc?.id);
const nextIndex = currentIndex !== -1 && currentIndex + 1 < data.length ? currentIndex + 1 : 0;
const nextDoc = data[nextIndex];
setTimeout(() => {
setSelectedDoc(nextDoc);
setEditModalOpen(true);
}, 300);
} else {
setSelectedDoc(null);
}
};
const popoverContent = (id: number) => (
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
width={400}
preview={false}
/>
);
const columns = [
{
title: 'Vorschau',
key: 'preview',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={100}
preview={false}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
</Popover>
),
},
{
title: 'Titel',
dataIndex: 'title',
key: 'title',
width: '35%',
},
{
title: 'Eingangsdatum',
key: 'eingangsdatum',
render: (_: any, record: PosteingangDocument) => {
return record.created ? dayjs(record.created).format('DD.MM.YYYY') : '-';
}
},
{
title: 'Importiert am',
dataIndex: 'added',
key: 'added',
render: (text: string) => dayjs(text).format('DD.MM.YYYY HH:mm'),
},
{
title: 'Aktion',
key: 'action',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Button type="primary" onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
),
},
];
return (
<ConfigProvider>
<div style={{ padding: '0 24px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Manuell bearbeiten</h2>
<Space>
<Tooltip title="Manuell aktualisieren">
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading} />
</Tooltip>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<DocumentEditModal
documentId={selectedDoc?.id || null}
document={selectedDoc}
open={editModalOpen}
onClose={handleCloseModal}
onSave={fetchData}
isPosteingang={false}
hasNextDocument={data.length > 1}
/>
</div>
</ConfigProvider>
);
}
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import { Table, Popover, Image, Button, Space, message, ConfigProvider, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
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';
export default function PosteingangPage() {
const [data, setData] = useState<PosteingangDocument[]>([]);
const [loading, setLoading] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<PosteingangDocument | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const docs = await posteingangApi.getList();
setData(docs || []);
} catch (e) {
message.error("Posteingang konnte nicht geladen werden.");
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
// Refresh interval every 30 seconds
const interval = setInterval(() => {
fetchData();
}, 30000);
return () => clearInterval(interval);
}, []);
const handleEdit = (doc: PosteingangDocument) => {
setSelectedDoc(doc);
setEditModalOpen(true);
};
const handleCloseModal = (openNext?: boolean) => {
setEditModalOpen(false);
if (openNext && data.length > 0) {
const currentIndex = data.findIndex(d => d.id === selectedDoc?.id);
const nextIndex = currentIndex !== -1 && currentIndex + 1 < data.length ? currentIndex + 1 : 0;
const nextDoc = data[nextIndex];
setTimeout(() => {
setSelectedDoc(nextDoc);
setEditModalOpen(true);
}, 300);
} else {
setSelectedDoc(null);
}
};
const popoverContent = (id: number) => (
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${id}`}
width={400}
preview={false}
/>
);
const columns = [
{
title: 'Vorschau',
key: 'preview',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Popover content={popoverContent(record.id)} placement="right" trigger="hover">
<Image
src={`${getEnv('VITE_API_URL')}/api/paperless/inbox/preview/${record.id}`}
width={100}
preview={false}
style={{ border: '1px solid #d9d9d9', objectFit: 'contain' }}
/>
</Popover>
),
},
{
title: 'Titel',
dataIndex: 'title',
key: 'title',
width: '35%',
},
{
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') : '-';
}
},
{
title: 'Importiert am',
dataIndex: 'added',
key: 'added',
render: (text: string) => dayjs(text).format('DD.MM.YYYY HH:mm'),
},
{
title: 'Aktion',
key: 'action',
width: 150,
render: (_: any, record: PosteingangDocument) => (
<Button type="primary" onClick={() => handleEdit(record)}>
Bearbeiten
</Button>
),
},
];
return (
<ConfigProvider>
<div style={{ padding: '0 24px 24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Posteingang</h2>
<Space>
<Tooltip title="Manuell aktualisieren">
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading} />
</Tooltip>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20 }}
/>
<DocumentEditModal
documentId={selectedDoc?.id || null}
document={selectedDoc}
open={editModalOpen}
onClose={handleCloseModal}
onSave={fetchData}
isPosteingang={true}
hasNextDocument={data.length > 1}
/>
</div>
</ConfigProvider>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
import { useEffect, useState } from 'react';
import { Table, Button, Space, Tag, Tooltip, Popconfirm, message, ConfigProvider } from 'antd';
import { ReloadOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { tasksApi } from '../api/tasks';
import type { Task } from '../api/tasks';
function statusTag(fertig: number | null) {
if (fertig === 1) return <Tag color="success">Fertig</Tag>;
if (fertig === 0) return <Tag color="warning">Ausstehend</Tag>;
return <Tag color="default">Neu</Tag>;
}
export default function TaskLogPage() {
const [data, setData] = useState<Task[]>([]);
const [loading, setLoading] = useState(false);
const [deleting, setDeleting] = useState(false);
const fetchData = async () => {
setLoading(true);
try {
const tasks = await tasksApi.getAll();
setData(tasks || []);
} catch {
message.error('Task-Log konnte nicht geladen werden.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
const handleDeleteFertige = async () => {
setDeleting(true);
try {
const result = await tasksApi.deleteFertige();
message.success(`${result.deleted} erledigte Task(s) gelöscht.`);
await fetchData();
} catch {
message.error('Fehler beim Löschen der erledigten Tasks.');
} finally {
setDeleting(false);
}
};
const handleDeleteOne = async (taskId: string) => {
try {
await tasksApi.deleteOne(taskId);
message.success('Task gelöscht.');
await fetchData();
} catch {
message.error('Task konnte nicht gelöscht werden.');
}
};
const columns = [
{
title: 'Task-ID',
dataIndex: 'TaskId',
key: 'TaskId',
width: 100,
render: (id: string) => (
<Tooltip title={id}>
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{id.slice(0, 8)}</span>
</Tooltip>
),
},
{
title: 'Interne Belegnr.',
dataIndex: 'InterneBelegnummer',
key: 'InterneBelegnummer',
},
{
title: 'Lieferant',
dataIndex: 'Lieferant',
key: 'Lieferant',
render: (v: string | null) => v || '-',
},
{
title: 'Belegdatum',
dataIndex: 'Belegdatum',
key: 'Belegdatum',
render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY') : '-'),
},
{
title: 'Eingangsdatum',
dataIndex: 'Eingangsdatum',
key: 'Eingangsdatum',
render: (v: string | null) => (v ? dayjs(v).format('DD.MM.YYYY') : '-'),
},
{
title: 'Paperless-Dok.-ID',
dataIndex: 'PaperlessDocumentID',
key: 'PaperlessDocumentID',
render: (v: number | null) => v ?? '-',
},
{
title: 'Status',
dataIndex: 'Fertig',
key: 'Fertig',
render: (v: number | null) => statusTag(v),
filters: [
{ text: 'Fertig', value: 1 },
{ text: 'Ausstehend', value: 0 },
{ text: 'Neu', value: null as any },
],
onFilter: (value: any, record: Task) => record.Fertig === value,
},
{
title: '',
key: 'actions',
width: 60,
render: (_: any, record: Task) => (
<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 />} />
</Popconfirm>
),
},
];
return (
<ConfigProvider>
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 16 }}>
<Space>
<Tooltip title="Manuell aktualisieren">
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading} />
</Tooltip>
<Popconfirm
title="Erledigte Tasks löschen"
description="Alle Tasks mit Status 'Fertig' werden dauerhaft aus der Datenbank entfernt."
onConfirm={handleDeleteFertige}
okText="Löschen"
cancelText="Abbrechen"
okButtonProps={{ danger: true }}
>
<Button
danger
icon={<DeleteOutlined />}
loading={deleting}
>
Erledigte löschen
</Button>
</Popconfirm>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="TaskId"
loading={loading}
pagination={{ pageSize: 20 }}
/>
</div>
</ConfigProvider>
);
}
@@ -0,0 +1,39 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
type ThemeMode = 'dark' | 'light';
interface ThemeContextType {
themeMode: ThemeMode;
toggleTheme: () => void;
isDark: boolean;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const STORAGE_KEY = 'paperless-theme-mode';
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
const stored = localStorage.getItem(STORAGE_KEY);
return (stored === 'light' || stored === 'dark') ? stored : 'dark';
});
useEffect(() => {
localStorage.setItem(STORAGE_KEY, themeMode);
document.documentElement.setAttribute('data-theme', themeMode);
}, [themeMode]);
const toggleTheme = () => setThemeMode((prev) => (prev === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ themeMode, toggleTheme, isDark: themeMode === 'dark' }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+9
View File
@@ -0,0 +1,9 @@
/**
* Gibt eine Umgebungsvariable zurück.
* Priorisierung: window.__ENV__ (Docker Runtime) > import.meta.env (Vite Build-Time)
*/
export function getEnv(key: string): string {
const runtimeEnv = (window as any).__ENV__;
if (runtimeEnv?.[key]) return runtimeEnv[key];
return (import.meta.env as any)[key] ?? '';
}
+11
View File
@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_OIDC_AUTHORITY: string;
readonly VITE_OIDC_CLIENT_ID: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
envDir: '../', // Lädt .env aus dem Projekt-Root
})