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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user