feat(reports): DATEV-Monatsblatt-Export (Blanko-Vorlage) für Steuerberater

Neuer PDF-Export GET /reports/datev-monthly/export: ein Blatt pro
Mitarbeiter/Monat im Layout der DATEV-Stundenaufzeichnungs-Vorlage
(Beginn/Pause/Ende/Dauer + K/U/UU/F/SA/SU-Kürzel + Bemerkungen +
Summe + Unterschriftsfelder). Führt time_entries, Absences und
Feiertage pro Kalendertag zusammen.

Außerdem: Mustervorlagen für neue Backend-Module (Model/Schema/Router/
Migration/Test) und eine Frontend-Page-Vorlage, abgeleitet vom
hours_payouts-Modul als aktuellstem sauberen Muster.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CviFgc739S89xS97pvjszj
This commit is contained in:
2026-08-06 22:19:54 +02:00
co-authored by Claude Sonnet 5
parent f3ed234e56
commit 1dcc04ae07
10 changed files with 901 additions and 2 deletions
+230
View File
@@ -0,0 +1,230 @@
// TEMPLATE Mustervorlage für eine neue Seite mit Liste + Modal + Approve/Reject.
// Abgeleitet von pages/HoursPayoutPage.tsx.
//
// Anleitung:
// 1. Nach pages/XxxThingsPage.tsx kopieren, XxxThing/xxx-things ersetzen
// 2. Route in App.tsx registrieren, Nav-Eintrag in components/navConfig.ts
// 3. Konventionen: kein useEffect wenn Event-Handler reicht, Fehler als
// string | null, Lade-Zustand mit <Spinner />, Modal mit <Modal>-Komponente
// 4. Bauen + deployen: npm run build && rsync -az --delete ./dist/ root@192.168.1.137:/opt/timemaster/frontend/dist/
import { useEffect, useState } from 'react'
import { api } from '../api/client'
import { Spinner } from '../components/Spinner'
import { Layout } from '../components/Layout'
import { Modal } from '../components/Modal'
interface XxxThingOut {
id: string
user_name: string
note: string | null
status: string
rejection_reason: string | null
created_at: string
}
interface XxxThingListResponse {
items: XxxThingOut[]
total_count: number
}
interface Me {
first_name: string
last_name: string
role: string
}
const inputClass = 'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-purple-400 focus:border-transparent'
const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
requested: { label: 'Beantragt', cls: 'bg-amber-100 text-amber-700' },
approved: { label: 'Genehmigt', cls: 'bg-green-100 text-green-700' },
rejected: { label: 'Abgelehnt', cls: 'bg-red-100 text-red-700' },
}
function StatusBadge({ status }: { status: string }) {
const b = STATUS_BADGE[status] ?? { label: status, cls: 'bg-gray-100 text-gray-500' }
return <span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${b.cls}`}>{b.label}</span>
}
export function XxxThingsPage() {
const [me, setMe] = useState<Me | null>(null)
const [pageLoading, setPageLoading] = useState(true)
const [pageError, setPageError] = useState<string | null>(null)
const [items, setItems] = useState<XxxThingOut[]>([])
const [tableLoading, setTableLoading] = useState(false)
const [showModal, setShowModal] = useState(false)
const [formNote, setFormNote] = useState('')
const [modalSaving, setModalSaving] = useState(false)
const [modalError, setModalError] = useState<string | null>(null)
useEffect(() => {
async function init() {
try {
const meData = await api.get<Me>('/auth/me')
setMe(meData)
await loadItems()
} catch (e: unknown) {
setPageError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setPageLoading(false)
}
}
init()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
async function loadItems() {
setTableLoading(true)
try {
const data = await api.get<XxxThingListResponse>('/xxx-things')
setItems(data.items)
} catch (e: unknown) {
setPageError(e instanceof Error ? e.message : 'Fehler beim Laden')
} finally {
setTableLoading(false)
}
}
function openNewModal() {
setFormNote('')
setModalError(null)
setShowModal(true)
}
async function handleSave() {
setModalSaving(true)
setModalError(null)
try {
await api.post<XxxThingOut>('/xxx-things', { note: formNote.trim() || null })
setShowModal(false)
loadItems()
} catch (e: unknown) {
setModalError(e instanceof Error ? e.message : 'Fehler beim Anlegen')
} finally {
setModalSaving(false)
}
}
async function handleApprove(item: XxxThingOut) {
if (!confirm(`Eintrag für ${item.user_name} genehmigen?`)) return
try {
const updated = await api.post<XxxThingOut>(`/xxx-things/${item.id}/approve`, {})
setItems(prev => prev.map(i => (i.id === item.id ? updated : i)))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler beim Genehmigen')
}
}
async function handleReject(item: XxxThingOut) {
const reason = prompt('Grund der Ablehnung (optional):')
if (reason === null) return
try {
const updated = await api.post<XxxThingOut>(`/xxx-things/${item.id}/reject`, {
rejection_reason: reason.trim() || null,
})
setItems(prev => prev.map(i => (i.id === item.id ? updated : i)))
} catch (e: unknown) {
alert(e instanceof Error ? e.message : 'Fehler beim Ablehnen')
}
}
if (pageLoading) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'><Spinner /></div>
)
if (pageError) return (
<div className='min-h-screen bg-gray-50 flex items-center justify-center'>
<p className='text-red-600'>{pageError}</p>
</div>
)
return (
<Layout userRole={me?.role ?? ''} userName={`${me?.first_name} ${me?.last_name}`}>
<div className='space-y-6'>
<div className='flex items-center justify-between'>
<div>
<h1 className='text-xl font-bold text-gray-800'>Xxx-Things</h1>
<p className='text-sm text-gray-500 mt-0.5'>Kurzbeschreibung der Seite</p>
</div>
<button
onClick={openNewModal}
className='px-4 py-2 bg-purple-600 text-white text-sm font-semibold rounded-lg hover:bg-purple-700 transition-colors'
>
+ Neu anlegen
</button>
</div>
<div className='bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden'>
{tableLoading ? (
<div className='flex items-center justify-center py-16'><Spinner /></div>
) : (
<div className='overflow-x-auto'>
<table className='w-full text-sm'>
<thead className='bg-gray-50 text-gray-500 text-xs uppercase'>
<tr>
{['Mitarbeiter', 'Status', 'Notiz', 'Datum', 'Aktion'].map(h => (
<th key={h} className='px-4 py-3 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{items.map(item => (
<tr key={item.id} className='hover:bg-gray-50 transition-colors'>
<td className='px-4 py-3 font-medium text-gray-800'>{item.user_name}</td>
<td className='px-4 py-3'><StatusBadge status={item.status} /></td>
<td className='px-4 py-3 text-gray-500 max-w-xs truncate'>{item.note || '—'}</td>
<td className='px-4 py-3 text-gray-500'>
{new Date(item.created_at).toLocaleDateString('de-DE')}
</td>
<td className='px-4 py-3'>
{item.status === 'requested' && (
<div className='flex gap-2'>
<button onClick={() => handleApprove(item)} className='text-xs text-green-600 hover:underline font-medium'> Genehmigen</button>
<button onClick={() => handleReject(item)} className='text-xs text-red-500 hover:underline'> Ablehnen</button>
</div>
)}
</td>
</tr>
))}
{items.length === 0 && (
<tr><td colSpan={5} className='px-4 py-10 text-center text-gray-400'>Keine Einträge gefunden.</td></tr>
)}
</tbody>
</table>
</div>
)}
</div>
</div>
{showModal && (
<Modal title='Neu anlegen' onClose={() => setShowModal(false)}>
<div className='space-y-4'>
<label className='block'>
<span className='text-xs font-medium text-gray-700'>Notiz</span>
<textarea
value={formNote}
onChange={e => setFormNote(e.target.value)}
maxLength={500}
rows={3}
className={inputClass + ' resize-none'}
/>
</label>
{modalError && (
<div className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{modalError}</div>
)}
<div className='flex justify-end gap-2 pt-2'>
<button onClick={() => setShowModal(false)} className='px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50'>Abbrechen</button>
<button onClick={handleSave} disabled={modalSaving} className='px-4 py-2 text-sm font-semibold text-white bg-purple-600 rounded-lg hover:bg-purple-700 disabled:opacity-50'>
{modalSaving ? 'Speichere…' : 'Anlegen'}
</button>
</div>
</div>
</Modal>
)}
</Layout>
)
}