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:
@@ -1134,3 +1134,31 @@ Keine Commits in dieser Session.
|
|||||||
- frontend/src/pages/LoginPage.tsx | 11 +++++++++--
|
- frontend/src/pages/LoginPage.tsx | 11 +++++++++--
|
||||||
|
|
||||||
---
|
---
|
||||||
|
## 2026-05-24 23:28 – 23:31 (2m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
- 22be68e feat: Abwesenheiten-Screen in Mobile-App
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 40 +++
|
||||||
|
- frontend/src/pages/mobile/MobileAbsencesScreen.tsx | 370 +++++++++++++++++++++
|
||||||
|
- frontend/src/pages/mobile/MobileBottomNav.tsx | 16 +-
|
||||||
|
- frontend/src/pages/mobile/MobilePage.tsx | 17 +-
|
||||||
|
|
||||||
|
---
|
||||||
|
## 2026-05-24 23:33 – 23:33 (0m)
|
||||||
|
**Beschreibung:** Claude Code Session
|
||||||
|
**Projekt:** timemaster
|
||||||
|
|
||||||
|
### Commits
|
||||||
|
Keine Commits in dieser Session.
|
||||||
|
|
||||||
|
### Geänderte Dateien
|
||||||
|
- DEVLOG.md | 40 +++
|
||||||
|
- frontend/src/pages/mobile/MobileAbsencesScreen.tsx | 370 +++++++++++++++++++++
|
||||||
|
- frontend/src/pages/mobile/MobileBottomNav.tsx | 16 +-
|
||||||
|
- frontend/src/pages/mobile/MobilePage.tsx | 17 +-
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class Company(Base):
|
|||||||
kiosk_track_current_user: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
kiosk_track_current_user: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
kiosk_heartbeat_interval_sec: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
|
kiosk_heartbeat_interval_sec: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
|
||||||
|
|
||||||
|
# Mobile-Konfiguration
|
||||||
|
mobile_stamping_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
|
users: Mapped[list["User"]] = relationship("User", back_populates="company", lazy="noload")
|
||||||
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
|
departments: Mapped[list["Department"]] = relationship("Department", back_populates="company", lazy="noload")
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class CompanyOut(BaseModel):
|
|||||||
personnel_number_required: bool = False
|
personnel_number_required: bool = False
|
||||||
personnel_number_mode: PersonnelNumberModeT = "manual"
|
personnel_number_mode: PersonnelNumberModeT = "manual"
|
||||||
personnel_number_next: int = 1
|
personnel_number_next: int = 1
|
||||||
|
mobile_stamping_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
class CompanyUpdate(BaseModel):
|
class CompanyUpdate(BaseModel):
|
||||||
@@ -30,6 +31,7 @@ class CompanyUpdate(BaseModel):
|
|||||||
personnel_number_required: bool | None = None
|
personnel_number_required: bool | None = None
|
||||||
personnel_number_mode: PersonnelNumberModeT | None = None
|
personnel_number_mode: PersonnelNumberModeT | None = None
|
||||||
personnel_number_next: int | None = Field(None, ge=1)
|
personnel_number_next: int | None = Field(None, ge=1)
|
||||||
|
mobile_stamping_enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
class DepartmentOut(BaseModel):
|
class DepartmentOut(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Mobile-Konfiguration: mobile_stamping_enabled
|
||||||
|
|
||||||
|
Revision ID: 0027
|
||||||
|
Revises: 0026
|
||||||
|
Create Date: 2026-05-24
|
||||||
|
|
||||||
|
Neues Feld in companies:
|
||||||
|
mobile_stamping_enabled BOOLEAN DEFAULT TRUE
|
||||||
|
Steuert ob der Einstempel-Button in der mobilen Ansicht sichtbar ist.
|
||||||
|
Default TRUE: alle bestehenden Firmen bleiben unverändert.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "0027"
|
||||||
|
down_revision = "0026"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"companies",
|
||||||
|
sa.Column("mobile_stamping_enabled", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("companies", "mobile_stamping_enabled")
|
||||||
@@ -53,6 +53,8 @@ export function CompanySettingsPage() {
|
|||||||
const [pnRequired, setPnRequired] = useState(false)
|
const [pnRequired, setPnRequired] = useState(false)
|
||||||
const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual')
|
const [pnMode, setPnMode] = useState<'manual' | 'auto'>('manual')
|
||||||
const [pnNext, setPnNext] = useState(1)
|
const [pnNext, setPnNext] = useState(1)
|
||||||
|
// Mobile
|
||||||
|
const [mobileStamping, setMobileStamping] = useState(true)
|
||||||
// Busylight
|
// Busylight
|
||||||
const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
|
const [blStatus, setBlStatus] = useState<{ configured: boolean; created_at: string | null } | null>(null)
|
||||||
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
|
const [blPlaintext, setBlPlaintext] = useState<string | null>(null)
|
||||||
@@ -76,6 +78,7 @@ export function CompanySettingsPage() {
|
|||||||
setPnRequired(c.personnel_number_required ?? false)
|
setPnRequired(c.personnel_number_required ?? false)
|
||||||
setPnMode(c.personnel_number_mode ?? 'manual')
|
setPnMode(c.personnel_number_mode ?? 'manual')
|
||||||
setPnNext(c.personnel_number_next ?? 1)
|
setPnNext(c.personnel_number_next ?? 1)
|
||||||
|
setMobileStamping((c as CompanyOut & { mobile_stamping_enabled?: boolean }).mobile_stamping_enabled ?? true)
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
|
api.get<{ configured: boolean; created_at: string | null }>('/companies/me/busylight-token')
|
||||||
.then(setBlStatus)
|
.then(setBlStatus)
|
||||||
@@ -143,6 +146,7 @@ export function CompanySettingsPage() {
|
|||||||
personnel_number_required: pnRequired,
|
personnel_number_required: pnRequired,
|
||||||
personnel_number_mode: pnMode,
|
personnel_number_mode: pnMode,
|
||||||
personnel_number_next: pnNext,
|
personnel_number_next: pnNext,
|
||||||
|
mobile_stamping_enabled: mobileStamping,
|
||||||
})
|
})
|
||||||
setCompany(updated)
|
setCompany(updated)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
@@ -519,6 +523,37 @@ export function CompanySettingsPage() {
|
|||||||
</div>
|
</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) */}
|
{/* Firmen-Info (readonly) */}
|
||||||
{company && (
|
{company && (
|
||||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface TodayStatus {
|
|||||||
break_minutes?: number
|
break_minutes?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CompanySettings {
|
||||||
|
mobile_stamping_enabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
interface TimeEntryWithWarnings {
|
interface TimeEntryWithWarnings {
|
||||||
entry: { id: string }
|
entry: { id: string }
|
||||||
warnings: string[]
|
warnings: string[]
|
||||||
@@ -64,6 +68,7 @@ function fmtTime(iso: string | null): string {
|
|||||||
export function MobileStampScreen() {
|
export function MobileStampScreen() {
|
||||||
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
|
const [dashboard, setDashboard] = useState<TodayStatus | null>(null)
|
||||||
const [balance, setBalance] = useState<BalanceResponse | null>(null)
|
const [balance, setBalance] = useState<BalanceResponse | null>(null)
|
||||||
|
const [stampingAllowed, setStampingAllowed] = useState(true)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [stamping, setStamping] = useState(false)
|
const [stamping, setStamping] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
@@ -78,12 +83,14 @@ export function MobileStampScreen() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const monday = getMondayOfCurrentWeek()
|
const monday = getMondayOfCurrentWeek()
|
||||||
const [dash, bal] = await Promise.all([
|
const [dash, bal, company] = await Promise.all([
|
||||||
api.get<TodayStatus>('/dashboard/me'),
|
api.get<TodayStatus>('/dashboard/me'),
|
||||||
api.get<BalanceResponse>(`/time/balance/me?period_start=${monday}`),
|
api.get<BalanceResponse>(`/time/balance/me?period_start=${monday}`),
|
||||||
|
api.get<CompanySettings>('/companies/me'),
|
||||||
])
|
])
|
||||||
setDashboard(dash)
|
setDashboard(dash)
|
||||||
setBalance(bal)
|
setBalance(bal)
|
||||||
|
setStampingAllowed(company.mobile_stamping_enabled ?? true)
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -190,6 +197,20 @@ export function MobileStampScreen() {
|
|||||||
</div>
|
</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 */}
|
{/* 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'>
|
<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 */}
|
{/* Status-Label */}
|
||||||
@@ -232,8 +253,8 @@ export function MobileStampScreen() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Haupt-Button */}
|
{/* Haupt-Button – nur wenn Stempeln erlaubt */}
|
||||||
{!isOpen ? (
|
{stampingAllowed && !isOpen ? (
|
||||||
<button
|
<button
|
||||||
onClick={stampIn}
|
onClick={stampIn}
|
||||||
disabled={stamping}
|
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' />
|
<span className='animate-spin rounded-full h-7 w-7 border-4 border-white border-t-transparent' />
|
||||||
) : 'EINSTEMPELN'}
|
) : 'EINSTEMPELN'}
|
||||||
</button>
|
</button>
|
||||||
) : isOnBreak ? (
|
) : stampingAllowed && isOnBreak ? (
|
||||||
<button
|
<button
|
||||||
onClick={breakEnd}
|
onClick={breakEnd}
|
||||||
disabled={stamping}
|
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' />
|
<span className='animate-spin rounded-full h-7 w-7 border-4 border-white border-t-transparent' />
|
||||||
) : 'PAUSE BEENDEN'}
|
) : 'PAUSE BEENDEN'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : stampingAllowed ? (
|
||||||
<button
|
<button
|
||||||
onClick={stampOut}
|
onClick={stampOut}
|
||||||
disabled={stamping}
|
disabled={stamping}
|
||||||
@@ -265,8 +286,10 @@ export function MobileStampScreen() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Sekundärer Button: Pause starten */}
|
{/* Sekundärer Button: Pause starten */}
|
||||||
{isOpen && !isOnBreak && (
|
{stampingAllowed && isOpen && !isOnBreak && (
|
||||||
<button
|
<button
|
||||||
onClick={breakStart}
|
onClick={breakStart}
|
||||||
disabled={stamping}
|
disabled={stamping}
|
||||||
|
|||||||
Reference in New Issue
Block a user