Files
timemaster/frontend/src/pages/QrStampSettingsPage.tsx
T
patrickandClaude Opus 4.8 da4745013e feat: QR-Stempeln als eigener Menüpunkt + Tablet-Link
QR-Stempeln aus CompanySettingsPage in eigene Seite /settings/qr-stamp
ausgelagert, eigener Nav-Eintrag 'QR-Stempeln' (COMPANY_ADMIN/SUPER_ADMIN).
Toggle speichert jetzt eigenständig (PATCH public_stamp_enabled). Neuer
Tablet-Link-Bereich: Direkt-URL kopieren + 'Auf diesem Gerät öffnen' zum
dauerhaften Einrichten eines Tablets am Eingang (gleicher Token wie Handy-QR).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:10:48 +02:00

248 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<UserOut | null>(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<string | null>(null)
const [psQr, setPsQr] = useState<string | null>(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<string | null>(null)
useEffect(() => {
api.get<UserOut>('/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(`<!doctype html><html><head><title>QR-Stempel</title>
<style>body{font-family:sans-serif;text-align:center;padding:40px}
h1{font-size:22px}img{width:340px;height:340px}p{color:#555;font-size:15px}</style></head>
<body><h1>Zeiterfassung ${companyName || ''}</h1>
<img src="${psQr}" alt="QR" />
<p>Mit dem Handy scannen, dann Personalnummer + PIN eingeben und ein-/ausstempeln.</p>
<script>window.onload=function(){window.print()}</script></body></html>`)
w.document.close()
}
const isAdmin = me?.role === 'COMPANY_ADMIN' || me?.role === 'SUPER_ADMIN'
return (
<Layout userRole={me?.role ?? ''} userName={me ? `${me.first_name} ${me.last_name}` : ''}>
<div className="max-w-2xl mx-auto space-y-6">
<h1 className="text-2xl font-bold text-gray-900">QR-Stempeln (Handy &amp; Tablet)</h1>
{!isAdmin && (
<div className="rounded-lg border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500">
Diese Einstellung ist nur für Firmen-Administratoren verfügbar.
</div>
)}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
)}
{saved && (
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700">Gespeichert.</div>
)}
{isAdmin && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
<div className="flex items-center gap-2">
<span className="text-lg">📲</span>
<h2 className="font-semibold text-gray-700">QR-Stempeln</h2>
</div>
<p className="text-xs text-gray-400 -mt-2">
Statischer Zugang zum Ein-/Ausstempeln. Mitarbeiter scannen den QR-Code mit dem privaten Handy
oder ihr richtet ein <strong>Tablet am Eingang</strong> dauerhaft auf der Stempel-Seite ein.
Anmeldung jeweils per Personalnummer + PIN.
</p>
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
Schwächer als Kiosk-Terminals: keine Geräte-Signatur. Schutz nur über PIN + Sperre nach
Fehlversuchen. Nur aktivieren, wenn benötigt.
</div>
{/* Toggle aktiv (eigenes Speichern) */}
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">QR-Stempeln aktivieren</p>
<p className="text-xs text-gray-500 mt-0.5">Wird sofort gespeichert.</p>
</div>
<button
type="button"
disabled={saving}
onClick={() => saveEnabled(!psEnabled)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none disabled:opacity-50 ${
psEnabled ? 'bg-blue-600' : 'bg-gray-300'
}`}
>
<span className={`pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform transition-transform duration-200 ${
psEnabled ? 'translate-x-5' : 'translate-x-0'
}`} />
</button>
</div>
<div className="text-sm">
{psStatus?.configured ? (
<div className="flex items-center gap-2 text-green-700">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" />
<span>Zugang aktiv
{psStatus.created_at && (
<span className="text-gray-400 ml-1">(seit {new Date(psStatus.created_at).toLocaleString('de-DE')})</span>
)}
</span>
</div>
) : (
<div className="flex items-center gap-2 text-gray-500">
<span className="inline-block w-2 h-2 rounded-full bg-gray-300" />
<span>Kein Zugang generiert</span>
</div>
)}
</div>
{psUrl && (
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-4 space-y-4">
<p className="text-xs font-semibold text-amber-800">
Jetzt sichern der Link/QR-Code wird aus Sicherheitsgründen nur einmal angezeigt.
</p>
{psQr && (
<div className="flex justify-center">
<img src={psQr} alt="QR-Code" className="w-48 h-48 rounded-lg border border-amber-300 bg-white p-2" />
</div>
)}
{/* Tablet-Link */}
<div className="space-y-2">
<p className="text-xs font-semibold text-gray-700">🖥 Tablet-Link (Direktaufruf am Eingang)</p>
<div className="flex items-stretch gap-2">
<input type="text" readOnly value={psUrl}
onFocus={e => e.currentTarget.select()}
className="flex-1 font-mono text-xs px-3 py-2 border border-amber-300 rounded bg-white" />
<button onClick={copyPublicStampUrl}
className="px-3 py-2 bg-amber-600 text-white text-xs font-medium rounded hover:bg-amber-700">
{psCopied ? '✓ Kopiert' : 'Link kopieren'}
</button>
</div>
<p className="text-xs text-gray-500">
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.
</p>
<button onClick={openOnThisDevice}
className="w-full px-3 py-2 bg-blue-600 text-white text-xs font-medium rounded hover:bg-blue-700">
Auf diesem Gerät öffnen
</button>
</div>
<button onClick={printPublicStampQr}
className="w-full px-3 py-2 bg-gray-800 text-white text-xs font-medium rounded hover:bg-gray-900">
🖨 QR-Code drucken (Aushang)
</button>
</div>
)}
<div className="flex flex-wrap items-center gap-3">
<button onClick={rotatePublicStampToken} disabled={psBusy}
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50">
{psStatus?.configured ? 'Neuen Link/QR-Code generieren' : 'Link/QR-Code generieren'}
</button>
{psStatus?.configured && (
<button onClick={revokePublicStampToken} disabled={psBusy}
className="px-4 py-2 bg-white border border-red-300 text-red-700 text-sm font-medium rounded-lg hover:bg-red-50 disabled:opacity-50">
Deaktivieren
</button>
)}
{psStatus?.configured && (
<span className="text-xs text-gray-400">Beim Neugenerieren werden der bisherige Link und QR-Code ungültig.</span>
)}
</div>
</div>
)}
</div>
</Layout>
)
}