feat: Admin-Toggle für mobile Zeiterfassung

Backend:
- Company.mobile_stamping_enabled (BOOLEAN DEFAULT TRUE)
- CompanyOut + CompanyUpdate: neues Feld
- Migration 0027: companies.mobile_stamping_enabled

Frontend Desktop (CompanySettingsPage):
- Abschnitt 'Mobile-Ansicht' mit Toggle-Switch
- Speichert via PATCH /companies/me

Frontend Mobile (MobileStampScreen):
- Lädt mobile_stamping_enabled aus GET /companies/me
- Deaktiviert: Hinweis-Banner statt Buttons
  ('Einstempeln nicht verfügbar – bitte Kiosk/Desktop nutzen')
- Aktiviert: normales Verhalten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 23:52:46 +02:00
parent 22be68ee27
commit c8804efbd0
6 changed files with 126 additions and 6 deletions
@@ -53,6 +53,8 @@ export function CompanySettingsPage() {
const [pnRequired, setPnRequired] = useState(false)
const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual')
const [pnNext, setPnNext] = useState(1)
// Mobile
const [mobileStamping, setMobileStamping] = useState(true)
// Busylight
const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
@@ -76,6 +78,7 @@ export function CompanySettingsPage() {
setPnRequired(c.personnel_number_required ?? false)
setPnMode(c.personnel_number_mode ?? 'manual')
setPnNext(c.personnel_number_next ?? 1)
setMobileStamping((c as CompanyOut & { mobile_stamping_enabled?: boolean }).mobile_stamping_enabled ?? true)
}).catch(() => {})
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
.then(setBlStatus)
@@ -143,6 +146,7 @@ export function CompanySettingsPage() {
personnel_number_required: pnRequired,
personnel_number_mode: pnMode,
personnel_number_next: pnNext,
mobile_stamping_enabled: mobileStamping,
})
setCompany(updated)
setSaved(true)
@@ -519,6 +523,37 @@ export function CompanySettingsPage() {
</div>
)}
{/* Mobile-Einstellungen */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
<div className="flex items-center gap-2">
<span className="text-lg">📱</span>
<h2 className="font-semibold text-gray-700">Mobile-Ansicht</h2>
</div>
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-800">Einstempeln über Mobile erlauben</p>
<p className="text-xs text-gray-500 mt-0.5">
Wenn deaktiviert, sehen Mitarbeiter unter <code className="bg-gray-100 px-1 rounded">/mobile</code> keinen Stempel-Button.
Nützlich wenn Zeiterfassung nur über Kiosk-Terminals erfolgen soll.
</p>
</div>
<button
type="button"
disabled={!isAdmin}
onClick={() => setMobileStamping(v => !v)}
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 disabled:cursor-not-allowed ${
mobileStamping ? '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 ${
mobileStamping ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Firmen-Info (readonly) */}
{company && (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
@@ -9,6 +9,10 @@ interface TodayStatus {
break_minutes?: number
}
interface CompanySettings {
mobile_stamping_enabled: boolean
}
interface TimeEntryWithWarnings {
entry: { id: string }
warnings: string[]
@@ -64,6 +68,7 @@ function fmtTime(iso: string | null): string {
export function MobileStampScreen() {
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
const [balance, setBalance] = useState<BalanceResponse | null>(null)
const [stampingAllowed, setStampingAllowed] = useState(true)
const [loading, setLoading] = useState(true)
const [stamping, setStamping] = useState(false)
const [error, setError] = useState<string | null>(null)
@@ -78,12 +83,14 @@ export function MobileStampScreen() {
setError(null)
try {
const monday = getMondayOfCurrentWeek()
const [dash, bal] = await Promise.all([
const [dash, bal, company] = await Promise.all([
api.get<TodayStatus>('/dashboard/me'),
api.get<BalanceResponse>(`/time/balance/me?period_start=${monday}`),
api.get<CompanySettings>('/companies/me'),
])
setDashboard(dash)
setBalance(bal)
setStampingAllowed(company.mobile_stamping_enabled ?? true)
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
@@ -190,6 +197,20 @@ export function MobileStampScreen() {
</div>
)}
{/* Stempeln deaktiviert */}
{!stampingAllowed && (
<div className='bg-amber-50 border border-amber-200 rounded-xl px-4 py-4 flex gap-3 items-start'>
<span className='text-xl mt-0.5'>🔒</span>
<div>
<p className='text-sm font-semibold text-amber-800'>Einstempeln nicht verfügbar</p>
<p className='text-xs text-amber-700 mt-0.5'>
Dein Unternehmen hat die Zeiterfassung über die mobile Ansicht deaktiviert.
Bitte nutze das Kiosk-Terminal oder die Desktop-Version.
</p>
</div>
</div>
)}
{/* Status-Karte */}
<div className='bg-white rounded-2xl border border-gray-200 shadow-sm px-5 py-5 flex flex-col items-center gap-3'>
{/* Status-Label */}
@@ -232,8 +253,8 @@ export function MobileStampScreen() {
</div>
)}
{/* Haupt-Button */}
{!isOpen ? (
{/* Haupt-Button nur wenn Stempeln erlaubt */}
{stampingAllowed && !isOpen ? (
<button
onClick={stampIn}
disabled={stamping}
@@ -243,7 +264,7 @@ export function MobileStampScreen() {
<span className='animate-spin rounded-full h-7 w-7 border-4 border-white border-t-transparent' />
) : 'EINSTEMPELN'}
</button>
) : isOnBreak ? (
) : stampingAllowed && isOnBreak ? (
<button
onClick={breakEnd}
disabled={stamping}
@@ -253,7 +274,7 @@ export function MobileStampScreen() {
<span className='animate-spin rounded-full h-7 w-7 border-4 border-white border-t-transparent' />
) : 'PAUSE BEENDEN'}
</button>
) : (
) : stampingAllowed ? (
<button
onClick={stampOut}
disabled={stamping}
@@ -265,8 +286,10 @@ export function MobileStampScreen() {
</button>
)}
) : null}
{/* Sekundärer Button: Pause starten */}
{isOpen && !isOnBreak && (
{stampingAllowed && isOpen && !isOnBreak && (
<button
onClick={breakStart}
disabled={stamping}