agent-07 phase 2: fix test isolation + CSV import UI

- Fix conftest.py: commit after each request in override_get_db so
  preview_csv's rollback no longer wipes the shared registered_user
  (root cause of 401 cascade across test_user_import + test_personnel_number)
- Fix limiter.enabled=False in client fixture (blocks rate-limit 429)
- Fix user_import_service: allow reactivation when personnel number
  belongs to the same user being reactivated
- Fix test_personnel_number: use PATCH /companies/me (not /companies/{id})
  and add try/finally cleanup for personnel_number_required flag
- Frontend UsersPage: add CSV import modal with template download,
  preview/validation table, and guarded apply button

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 21:07:32 +02:00
parent 39a0e370bc
commit fbc04bc2c0
5 changed files with 217 additions and 24 deletions
+180 -6
View File
@@ -85,6 +85,22 @@ interface InviteForm {
const EMPTY_INVITE: InviteForm = { email: '', first_name: '', last_name: '', role: 'EMPLOYEE', personnel_number: '', initial_password: '' }
interface ImportRowResult {
row: number
email: string
personnel_number: string | null
action: string
message: string | null
}
interface ImportResult {
total_rows: number
created: number
reactivated: number
errors: number
items: ImportRowResult[]
}
export function UsersPage() {
const [me, setMe] = useState<Me | null>(null)
const [users, setUsers] = useState<UserOut[]>([])
@@ -118,6 +134,14 @@ export function UsersPage() {
// Company-Settings (für Auto-Modus / Pflicht-Anzeige)
const [company, setCompany] = useState<CompanyOut | null>(null)
// CSV-Import modal
const [showImport, setShowImport] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<ImportResult | null>(null)
const [importLoading, setImportLoading] = useState(false)
const [importError, setImportError] = useState('')
const [importSuccess, setImportSuccess] = useState('')
async function load() {
try {
const [meData, listData, schedulesData] = await Promise.all([
@@ -251,6 +275,51 @@ export function UsersPage() {
return () => clearTimeout(handle)
}
async function handleImportPreview() {
if (!importFile) return
setImportLoading(true)
setImportError('')
setImportPreview(null)
try {
const form = new FormData()
form.append('file', importFile)
const result = await api.postForm<ImportResult>('/users/import/preview', form)
setImportPreview(result)
} catch (e: unknown) {
setImportError(e instanceof Error ? e.message : 'Fehler beim Prüfen')
} finally {
setImportLoading(false)
}
}
async function handleImportApply() {
if (!importFile) return
setImportLoading(true)
setImportError('')
setImportSuccess('')
try {
const form = new FormData()
form.append('file', importFile)
const result = await api.postForm<ImportResult>('/users/import/apply', form)
setImportSuccess(`Import abgeschlossen: ${result.created} angelegt, ${result.reactivated} reaktiviert, ${result.errors} Fehler.`)
setImportPreview(result)
setImportFile(null)
load()
} catch (e: unknown) {
setImportError(e instanceof Error ? e.message : 'Fehler beim Import')
} finally {
setImportLoading(false)
}
}
function openImportModal() {
setShowImport(true)
setImportFile(null)
setImportPreview(null)
setImportError('')
setImportSuccess('')
}
const filtered = users.filter(u =>
`${u.full_name} ${u.email} ${u.personnel_number ?? ''}`.toLowerCase().includes(search.toLowerCase())
)
@@ -275,12 +344,20 @@ export function UsersPage() {
<h1 className='text-xl font-bold text-gray-800'>Mitarbeiterverwaltung</h1>
<p className='text-sm text-gray-500 mt-0.5'>{total} Mitarbeiter gesamt</p>
</div>
<button
onClick={() => { setShowInvite(true); setInviteSuccess(''); setInviteError('') }}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Einladen
</button>
<div className='flex gap-2'>
<button
onClick={openImportModal}
className='px-4 py-2 bg-gray-100 text-gray-700 text-sm font-semibold rounded-lg hover:bg-gray-200 transition-colors border border-gray-200'
>
CSV Import
</button>
<button
onClick={() => { setShowInvite(true); setInviteSuccess(''); setInviteError('') }}
className='px-4 py-2 bg-blue-600 text-white text-sm font-semibold rounded-lg hover:bg-blue-700 transition-colors'
>
+ Einladen
</button>
</div>
</div>
{/* Search */}
@@ -480,6 +557,103 @@ export function UsersPage() {
</Modal>
)}
{/* CSV Import Modal */}
{showImport && (
<Modal title='Mitarbeiter per CSV importieren' onClose={() => setShowImport(false)}>
<div className='space-y-4'>
<div className='flex items-center justify-between bg-blue-50 border border-blue-100 rounded-lg px-3 py-2'>
<span className='text-xs text-blue-700'>Vorlage mit Pflicht- und Optionalspalten herunterladen</span>
<a
href='/api/v1/users/import-template.csv'
download='import-vorlage.csv'
className='text-xs font-semibold text-blue-600 hover:underline ml-4 whitespace-nowrap'
>
Vorlage herunterladen
</a>
</div>
<div>
<label className='block text-xs font-medium text-gray-700 mb-1'>CSV-Datei auswählen</label>
<input
type='file'
accept='.csv,text/csv'
onChange={e => {
setImportFile(e.target.files?.[0] ?? null)
setImportPreview(null)
setImportError('')
setImportSuccess('')
}}
className='block w-full text-sm text-gray-600 file:mr-3 file:py-1.5 file:px-3 file:rounded file:border-0 file:text-xs file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100'
/>
</div>
{importError && <p className='text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2'>{importError}</p>}
{importSuccess && <p className='text-sm text-green-700 bg-green-50 border border-green-200 rounded px-3 py-2'>{importSuccess}</p>}
{importPreview && (
<div className='space-y-2'>
<div className='flex gap-4 text-sm'>
<span className='text-green-700 font-medium'>{importPreview.created} neu</span>
<span className='text-blue-700 font-medium'>{importPreview.reactivated} reaktiviert</span>
<span className='text-red-600 font-medium'>{importPreview.errors} Fehler</span>
<span className='text-gray-500'>{importPreview.total_rows} Zeilen gesamt</span>
</div>
<div className='max-h-56 overflow-y-auto border border-gray-200 rounded-lg'>
<table className='w-full text-xs'>
<thead className='bg-gray-50 text-gray-500 sticky top-0'>
<tr>
{['Zeile', 'E-Mail', 'Pers.-Nr.', 'Aktion', 'Meldung'].map(h => (
<th key={h} className='px-3 py-2 text-left font-medium'>{h}</th>
))}
</tr>
</thead>
<tbody className='divide-y divide-gray-100'>
{importPreview.items.map((it, i) => (
<tr key={i} className={it.action === 'error' ? 'bg-red-50' : it.action === 'reactivated' ? 'bg-blue-50' : ''}>
<td className='px-3 py-1.5 text-gray-500'>{it.row || '—'}</td>
<td className='px-3 py-1.5 font-mono'>{it.email || '—'}</td>
<td className='px-3 py-1.5 font-mono'>{it.personnel_number || '—'}</td>
<td className='px-3 py-1.5'>
<span className={`font-semibold ${
it.action === 'error' ? 'text-red-600' :
it.action === 'reactivated' ? 'text-blue-600' :
'text-green-600'
}`}>
{it.action === 'error' ? 'Fehler' : it.action === 'reactivated' ? 'Reaktiviert' : 'Neu'}
</span>
</td>
<td className='px-3 py-1.5 text-gray-500'>{it.message || ''}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
<div className='flex justify-end gap-2 pt-1'>
<button onClick={() => setShowImport(false)} className='px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50'>Schließen</button>
<button
onClick={handleImportPreview}
disabled={!importFile || importLoading}
className='px-4 py-2 text-sm font-semibold text-white bg-gray-600 rounded-lg hover:bg-gray-700 disabled:opacity-50'
>
{importLoading && !importPreview ? 'Prüfe…' : 'Vorschau prüfen'}
</button>
{importPreview && importPreview.errors === 0 && !importSuccess && (
<button
onClick={handleImportApply}
disabled={importLoading}
className='px-4 py-2 text-sm font-semibold text-white bg-green-600 rounded-lg hover:bg-green-700 disabled:opacity-50'
>
{importLoading ? 'Importiere…' : `${importPreview.created + importPreview.reactivated} Benutzer importieren`}
</button>
)}
</div>
</div>
</Modal>
)}
{/* Edit User Modal */}
{editUser && (
<Modal title={`Mitarbeiter bearbeiten ${editUser.full_name}`} onClose={() => setEditUser(null)}>