diff --git a/DEVLOG.md b/DEVLOG.md index 02da615..74855ae 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1934,3 +1934,15 @@ Keine Commits in dieser Session. - frontend/src/pages/PublicStampPage.tsx | 265 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ --- +## 2026-06-02 18:27 – 20:06 (1h 38m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +- 548aebe fix: QR-Stempel-Status korrekt – Status vor mid-request commit ermitteln +- 3423b65 docs: DEVLOG für QR-Stempel-Feature + +### Geänderte Dateien +- backend/app/routers/public_stamp.py | 11 +++++++++-- + +--- diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index 09450d1..814aefe 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -598,3 +598,25 @@ Keine Commits in dieser Session. - frontend/src/pages/mobile/MobileTodayScreen.tsx | 225 ++++++++++++++++++------ --- +## 2026-06-02 21:25 – 21:26 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** timemaster + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/routers/public_stamp.py | 11 +++++++++-- + +--- +## 2026-06-02 21:50 – 21:53 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** frontend + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/routers/public_stamp.py | 11 +++++++++-- + +--- diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4ee210b..f6570a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { AbsenceTypesPage } from './pages/AbsenceTypesPage' import ImportPage from './pages/ImportPage' import UserImportPage from './pages/UserImportPage' import { CompanySettingsPage } from './pages/CompanySettingsPage' +import { QrStampSettingsPage } from './pages/QrStampSettingsPage' import { ProfilePage } from './pages/ProfilePage' import { KioskDevicesPage } from './pages/KioskDevicesPage' import { AuditLogPage } from './pages/AuditLogPage' @@ -60,6 +61,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index eb0c8b0..29c8b80 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -30,6 +30,7 @@ const SETTINGS_NAV: NavItem[] = [ { path: '/settings/absence-types', label: 'Abwesenheitstypen', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] }, { path: '/settings/company', label: 'Firma', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] }, { path: '/settings/kiosk', label: 'Kiosk-Geräte', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] }, + { path: '/settings/qr-stamp', label: 'QR-Stempeln', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] }, { path: '/users/import', label: 'Mitarbeiter-Import', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN'] }, { path: '/settings/import', label: 'Daten importieren', roles: ['COMPANY_ADMIN', 'SUPER_ADMIN', 'HR'] }, { path: '/settings/caldav', label: 'CalDAV' }, diff --git a/frontend/src/pages/CompanySettingsPage.tsx b/frontend/src/pages/CompanySettingsPage.tsx index e45f815..1850112 100644 --- a/frontend/src/pages/CompanySettingsPage.tsx +++ b/frontend/src/pages/CompanySettingsPage.tsx @@ -1,5 +1,4 @@ import { useEffect, useState } from 'react' -import QRCode from 'qrcode' import { api } from '../api/client' import { Layout } from '../components/Layout' @@ -76,13 +75,6 @@ export function CompanySettingsPage() { const [blPlaintext, setBlPlaintext] = useState(null) const [blBusy, setBlBusy] = useState(false) const [blCopied, setBlCopied] = useState(false) - // Öffentliches QR-Stempeln - const [psEnabled, setPsEnabled] = useState(false) - const [psStatus, setPsStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null) - const [psUrl, setPsUrl] = useState(null) - const [psQr, setPsQr] = useState(null) - const [psBusy, setPsBusy] = useState(false) - const [psCopied, setPsCopied] = useState(false) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [error, setError] = useState(null) @@ -118,68 +110,8 @@ export function CompanySettingsPage() { api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token') .then(setBlStatus) .catch(() => {}) - api.get<{ enabled: boolean; configured: boolean; created_at: string | null }>('/companies/me/public-stamp-token') - .then(s => { setPsStatus({ configured: s.configured, created_at: s.created_at }); setPsEnabled(s.enabled) }) - .catch(() => {}) }, []) - async function rotatePublicStampToken() { - setPsBusy(true) - setError(null) - try { - const res = await api.post<{ token: string; public_url: string; created_at: string }>( - '/companies/me/public-stamp-token/rotate', {} - ) - setPsUrl(res.public_url) - setPsStatus({ configured: true, created_at: res.created_at }) - setPsCopied(false) - setPsQr(await QRCode.toDataURL(res.public_url, { width: 320, margin: 2, errorCorrectionLevel: 'H' })) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Fehler beim Generieren des QR-Tokens') - } finally { - setPsBusy(false) - } - } - - async function revokePublicStampToken() { - if (!confirm('QR-Code wirklich deaktivieren? Bestehende ausgedruckte Codes funktionieren danach nicht mehr.')) return - setPsBusy(true) - setError(null) - try { - await api.del('/companies/me/public-stamp-token') - setPsStatus({ configured: false, created_at: null }) - setPsUrl(null) - setPsQr(null) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : 'Fehler beim Deaktivieren') - } finally { - setPsBusy(false) - } - } - - async function copyPublicStampUrl() { - if (!psUrl) return - try { - await navigator.clipboard.writeText(psUrl) - setPsCopied(true) - setTimeout(() => setPsCopied(false), 2000) - } catch { /* clipboard nicht verfügbar */ } - } - - function printPublicStampQr() { - if (!psQr) return - const w = window.open('', '_blank', 'width=600,height=800') - if (!w) return - w.document.write(`QR-Stempel - -

Zeiterfassung – ${name || company?.name || ''}

- QR -

Mit dem Handy scannen, dann Personalnummer + PIN eingeben und ein-/ausstempeln.

- `) - w.document.close() - } - async function rotateBusylightToken() { setBlBusy(true) setError(null) @@ -247,7 +179,6 @@ export function CompanySettingsPage() { kiosk_require_approval: kioskRequireApproval, kiosk_track_current_user: kioskTrackCurrentUser, kiosk_heartbeat_interval_sec: kioskHeartbeatIntervalSec, - public_stamp_enabled: psEnabled, overtime_cap_hours: overtimeCapEnabled ? overtimeCapHours : null, overtime_expiry_enabled: overtimeExpiryEnabled, overtime_expiry_month: overtimeExpiryMonth, @@ -641,103 +572,7 @@ export function CompanySettingsPage() { )} - {/* Öffentliches QR-Stempeln */} - {isAdmin && ( -
-
- 📲 -

QR-Stempeln (Handy)

-
-

- Statischer QR-Code zum Aushängen am Eingang. Mitarbeiter scannen mit dem privaten Handy, - melden sich per Personalnummer + PIN an und stempeln ein/aus. -

- -
- ⚠ Schwächer als Kiosk-Terminals: keine Geräte-Signatur. Schutz nur über PIN + Sperre nach - Fehlversuchen. Nur aktivieren, wenn benötigt. -
- - {/* Toggle aktiv */} -
-
-

QR-Stempeln aktivieren

-

Speichern nicht vergessen.

-
- -
- -
- {psStatus?.configured ? ( -
- - QR-Code aktiv - {psStatus.created_at && ( - (seit {new Date(psStatus.created_at).toLocaleString('de-DE')}) - )} - -
- ) : ( -
- - Kein QR-Code generiert -
- )} -
- - {psUrl && ( -
-

- ⚠ QR-Code jetzt drucken/sichern – die URL wird nur einmal angezeigt. -

- {psQr && ( -
- QR-Code -
- )} -
- e.currentTarget.select()} - className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white" /> - -
- -
- )} - -
- - {psStatus?.configured && ( - - )} - {psStatus?.configured && ( - Beim Neugenerieren wird der bisherige QR-Code ungültig. - )} -
-
- )} + {/* QR-Stempeln wurde in eine eigene Seite ausgelagert: /settings/qr-stamp */} {/* Mobile-Einstellungen */}
diff --git a/frontend/src/pages/QrStampSettingsPage.tsx b/frontend/src/pages/QrStampSettingsPage.tsx new file mode 100644 index 0000000..06b4f80 --- /dev/null +++ b/frontend/src/pages/QrStampSettingsPage.tsx @@ -0,0 +1,247 @@ +import { useEffect, useState } from 'react' +import QRCode from 'qrcode' +import { api } from '../api/client' +import { Layout } from '../components/Layout' + +interface UserOut { + id: string; first_name: string; last_name: string; email: string; role: string +} + +export function QrStampSettingsPage() { + const [me, setMe] = useState(null) + const [companyName, setCompanyName] = useState('') + + const [psEnabled, setPsEnabled] = useState(false) + const [psStatus, setPsStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null) + const [psUrl, setPsUrl] = useState(null) + const [psQr, setPsQr] = useState(null) + const [psBusy, setPsBusy] = useState(false) + const [psCopied, setPsCopied] = useState(false) + + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + api.get('/auth/me').then(setMe).catch(() => {}) + api.get<{ name: string }>('/companies/me').then(c => setCompanyName(c.name)).catch(() => {}) + api.get<{ enabled: boolean; configured: boolean; created_at: string | null }>('/companies/me/public-stamp-token') + .then(s => { setPsStatus({ configured: s.configured, created_at: s.created_at }); setPsEnabled(s.enabled) }) + .catch(() => {}) + }, []) + + async function saveEnabled(next: boolean) { + setSaving(true) + setError(null) + setSaved(false) + try { + await api.patch('/companies/me', { public_stamp_enabled: next }) + setPsEnabled(next) + setSaved(true) + setTimeout(() => setSaved(false), 3000) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Speichern') + } finally { + setSaving(false) + } + } + + async function rotatePublicStampToken() { + setPsBusy(true) + setError(null) + try { + const res = await api.post<{ token: string; public_url: string; created_at: string }>( + '/companies/me/public-stamp-token/rotate', {} + ) + setPsUrl(res.public_url) + setPsStatus({ configured: true, created_at: res.created_at }) + setPsCopied(false) + setPsQr(await QRCode.toDataURL(res.public_url, { width: 320, margin: 2, errorCorrectionLevel: 'H' })) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Generieren des QR-Tokens') + } finally { + setPsBusy(false) + } + } + + async function revokePublicStampToken() { + if (!confirm('QR-Code wirklich deaktivieren? Bestehende ausgedruckte Codes und eingerichtete Tablets funktionieren danach nicht mehr.')) return + setPsBusy(true) + setError(null) + try { + await api.del('/companies/me/public-stamp-token') + setPsStatus({ configured: false, created_at: null }) + setPsUrl(null) + setPsQr(null) + } catch (e: unknown) { + setError(e instanceof Error ? e.message : 'Fehler beim Deaktivieren') + } finally { + setPsBusy(false) + } + } + + async function copyPublicStampUrl() { + if (!psUrl) return + try { + await navigator.clipboard.writeText(psUrl) + setPsCopied(true) + setTimeout(() => setPsCopied(false), 2000) + } catch { /* clipboard nicht verfügbar */ } + } + + function openOnThisDevice() { + if (!psUrl) return + window.open(psUrl, '_blank', 'noopener') + } + + function printPublicStampQr() { + if (!psQr) return + const w = window.open('', '_blank', 'width=600,height=800') + if (!w) return + w.document.write(`QR-Stempel + +

Zeiterfassung – ${companyName || ''}

+ QR +

Mit dem Handy scannen, dann Personalnummer + PIN eingeben und ein-/ausstempeln.

+ `) + w.document.close() + } + + const isAdmin = me?.role === 'COMPANY_ADMIN' || me?.role === 'SUPER_ADMIN' + + return ( + +
+

QR-Stempeln (Handy & Tablet)

+ + {!isAdmin && ( +
+ Diese Einstellung ist nur für Firmen-Administratoren verfügbar. +
+ )} + + {error && ( +
{error}
+ )} + {saved && ( +
Gespeichert.
+ )} + + {isAdmin && ( +
+
+ 📲 +

QR-Stempeln

+
+

+ Statischer Zugang zum Ein-/Ausstempeln. Mitarbeiter scannen den QR-Code mit dem privaten Handy + oder ihr richtet ein Tablet am Eingang dauerhaft auf der Stempel-Seite ein. + Anmeldung jeweils per Personalnummer + PIN. +

+ +
+ ⚠ Schwächer als Kiosk-Terminals: keine Geräte-Signatur. Schutz nur über PIN + Sperre nach + Fehlversuchen. Nur aktivieren, wenn benötigt. +
+ + {/* Toggle aktiv (eigenes Speichern) */} +
+
+

QR-Stempeln aktivieren

+

Wird sofort gespeichert.

+
+ +
+ +
+ {psStatus?.configured ? ( +
+ + Zugang aktiv + {psStatus.created_at && ( + (seit {new Date(psStatus.created_at).toLocaleString('de-DE')}) + )} + +
+ ) : ( +
+ + Kein Zugang generiert +
+ )} +
+ + {psUrl && ( +
+

+ ⚠ Jetzt sichern – der Link/QR-Code wird aus Sicherheitsgründen nur einmal angezeigt. +

+ + {psQr && ( +
+ QR-Code +
+ )} + + {/* Tablet-Link */} +
+

🖥 Tablet-Link (Direktaufruf am Eingang)

+
+ e.currentTarget.select()} + className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white" /> + +
+

+ Diesen Link auf dem Tablet im Browser öffnen und als Startseite/Lesezeichen oder im + Kiosk-/Vollbildmodus fixieren. Derselbe Link funktioniert auch hinter dem QR-Code fürs Handy. +

+ +
+ + +
+ )} + +
+ + {psStatus?.configured && ( + + )} + {psStatus?.configured && ( + Beim Neugenerieren werden der bisherige Link und QR-Code ungültig. + )} +
+
+ )} +
+
+ ) +}