Mitarbeiter: CSV-Import-Assistent mit Mapping, Vorschau & Upsert

- EmployeeImportController POST /api/employees/import: feldzugeordnete Zeilen,
  optionales Unique-Feld (email/emailPrivate/phone/mobile) für Aktualisierung
  bestehender Datensätze; Adressfelder werden gemerged; eindeutiger Slug
- EmployeeImport.vue: CSV einlesen (Datei/Einfügen, Trennzeichen-Erkennung),
  Auto-Mapping per Header, manuelles Mapping, Vorschau erster Datensatz,
  Ergebnis (angelegt/aktualisiert/übersprungen + Hinweise)
- „Importieren"-Button in der Mitarbeiterliste

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 19:58:14 +02:00
parent e60871fcd3
commit b0396bcab8
3 changed files with 463 additions and 1 deletions

View File

@ -0,0 +1,166 @@
<?php
namespace App\Controller;
use App\Entity\Company;
use App\Entity\Employee;
use App\Security\TenantContext;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* Stapel-Import von Mitarbeiter-/Adressdaten für eine Firma (CSV Mapping im
* Frontend hier bereits feldzugeordnete Zeilen). Optional Aktualisierung
* bestehender Datensätze über ein wählbares Unique-Feld (Upsert).
*/
#[IsGranted('ROLE_COMPANY_ADMIN')]
final class EmployeeImportController
{
/** Skalare Profilfelder, die importiert werden dürfen. */
private const SCALAR = [
'salutation', 'title', 'firstName', 'lastName', 'position', 'department',
'email', 'emailPrivate', 'phone', 'mobile', 'fax', 'phoneCentral', 'website', 'bio',
];
/** Felder der Geschäftsadresse. */
private const ADDRESS = ['street', 'houseNumber', 'addressLine2', 'zip', 'city', 'state', 'country'];
/** Felder, die als Unique-Schlüssel zum Abgleich erlaubt sind. */
private const UNIQUE_ALLOWED = ['email', 'emailPrivate', 'phone', 'mobile', 'slug'];
public function __construct(
private readonly EntityManagerInterface $em,
private readonly TenantContext $tenant,
) {
}
#[Route('/api/employees/import', name: 'employees_import', methods: ['POST'])]
public function import(Request $request): JsonResponse
{
$company = $this->tenant->getCompany();
if (!$company instanceof Company) {
throw new BadRequestHttpException('Import bitte im Firmenkontext ausführen.');
}
$data = json_decode($request->getContent(), true) ?? [];
$rows = \is_array($data['rows'] ?? null) ? $data['rows'] : [];
$uniqueField = \in_array($data['uniqueField'] ?? null, self::UNIQUE_ALLOWED, true) ? $data['uniqueField'] : null;
$created = 0;
$updated = 0;
$skipped = 0;
$errors = [];
foreach ($rows as $i => $row) {
if (!\is_array($row)) {
continue;
}
$values = $this->extract($row);
$employee = null;
if (null !== $uniqueField && '' !== (string) ($values[$uniqueField] ?? '')) {
$employee = $this->em->getRepository(Employee::class)
->findOneBy(['company' => $company, $uniqueField => $values[$uniqueField]]);
}
if (null === $employee) {
if ('' === (string) ($values['firstName'] ?? '') || '' === (string) ($values['lastName'] ?? '')) {
++$skipped;
$errors[] = ['row' => $i + 1, 'message' => 'Vor- und Nachname nötig zum Anlegen.'];
continue;
}
$employee = (new Employee())
->setCompany($company)
->setStatus('active')
->setSlug($this->uniqueSlug($company, $values['firstName'], $values['lastName']));
$this->apply($employee, $values, true);
$this->em->persist($employee);
++$created;
} else {
$this->apply($employee, $values, false);
$employee->touch();
++$updated;
}
}
$this->em->flush();
return new JsonResponse([
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'errors' => \array_slice($errors, 0, 50),
]);
}
/**
* @param array<string, mixed> $row
*
* @return array<string, string>
*/
private function extract(array $row): array
{
$out = [];
foreach ([...self::SCALAR, ...self::ADDRESS] as $key) {
if (\array_key_exists($key, $row)) {
$out[$key] = trim((string) $row[$key]);
}
}
return $out;
}
/** @param array<string, string> $v */
private function apply(Employee $e, array $v, bool $isNew): void
{
$setters = [
'salutation' => 'setSalutation', 'title' => 'setTitle', 'firstName' => 'setFirstName',
'lastName' => 'setLastName', 'position' => 'setPosition', 'department' => 'setDepartment',
'email' => 'setEmail', 'emailPrivate' => 'setEmailPrivate', 'phone' => 'setPhone',
'mobile' => 'setMobile', 'fax' => 'setFax', 'phoneCentral' => 'setPhoneCentral',
'website' => 'setWebsite', 'bio' => 'setBio',
];
foreach ($setters as $key => $setter) {
if (\array_key_exists($key, $v) && '' !== $v[$key]) {
$e->{$setter}($v[$key]);
}
}
// Geschäftsadresse: vorhandene Werte beibehalten, neue überschreiben
$addr = $isNew ? [] : ($e->getAddressBusiness() ?? []);
$touched = false;
foreach (self::ADDRESS as $key) {
if (\array_key_exists($key, $v) && '' !== $v[$key]) {
$addr[$key] = $v[$key];
$touched = true;
}
}
if ($touched) {
$e->setAddressBusiness($addr);
}
}
private function uniqueSlug(Company $company, string $first, string $last): string
{
$base = $this->slugify($first.'-'.$last) ?: 'mitarbeiter';
$repo = $this->em->getRepository(Employee::class);
$slug = $base;
$n = 1;
while (null !== $repo->findOneBy(['company' => $company, 'slug' => $slug])) {
$slug = $base.'-'.(++$n);
}
return $slug;
}
private function slugify(string $s): string
{
$s = strtolower(trim($s));
$s = str_replace(['ä', 'ö', 'ü', 'ß'], ['ae', 'oe', 'ue', 'ss'], $s);
$s = preg_replace('/[^a-z0-9]+/', '-', $s) ?? '';
return trim($s, '-');
}
}

View File

@ -0,0 +1,286 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import client from '@/api/client'
import Modal from '@/components/Modal.vue'
const emit = defineEmits<{ close: []; done: [] }>()
interface Target { key: string; label: string }
const TARGETS: Target[] = [
{ key: 'salutation', label: 'Anrede' },
{ key: 'title', label: 'Titel' },
{ key: 'firstName', label: 'Vorname' },
{ key: 'lastName', label: 'Nachname' },
{ key: 'position', label: 'Position' },
{ key: 'department', label: 'Abteilung' },
{ key: 'email', label: 'E-Mail' },
{ key: 'emailPrivate', label: 'E-Mail privat' },
{ key: 'phone', label: 'Telefon' },
{ key: 'mobile', label: 'Mobil' },
{ key: 'fax', label: 'Fax' },
{ key: 'phoneCentral', label: 'Zentrale' },
{ key: 'website', label: 'Website' },
{ key: 'street', label: 'Straße' },
{ key: 'houseNumber', label: 'Nr.' },
{ key: 'addressLine2', label: 'Adresszusatz' },
{ key: 'zip', label: 'PLZ' },
{ key: 'city', label: 'Ort' },
{ key: 'state', label: 'Bundesland' },
{ key: 'country', label: 'Land' },
{ key: 'bio', label: 'Über mich' },
]
const UNIQUE_KEYS = ['email', 'emailPrivate', 'phone', 'mobile']
const SYNONYMS: Record<string, string> = {
anrede: 'salutation', salutation: 'salutation',
titel: 'title', title: 'title',
vorname: 'firstName', firstname: 'firstName', first: 'firstName',
nachname: 'lastName', lastname: 'lastName', last: 'lastName', name: 'lastName', surname: 'lastName',
position: 'position', funktion: 'position', rolle: 'position', role: 'position',
abteilung: 'department', department: 'department', dept: 'department',
email: 'email', 'e-mail': 'email', mail: 'email', emailgeschaeftlich: 'email',
emailprivat: 'emailPrivate', privatemail: 'emailPrivate', emailprivate: 'emailPrivate',
telefon: 'phone', phone: 'phone', tel: 'phone', festnetz: 'phone',
mobil: 'mobile', mobile: 'mobile', handy: 'mobile', cell: 'mobile',
fax: 'fax',
zentrale: 'phoneCentral', durchwahl: 'phoneCentral',
website: 'website', web: 'website', url: 'website', homepage: 'website',
strasse: 'street', street: 'street', str: 'street',
nr: 'houseNumber', hausnummer: 'houseNumber', hausnr: 'houseNumber', houseno: 'houseNumber',
adresszusatz: 'addressLine2', zusatz: 'addressLine2', line2: 'addressLine2',
plz: 'zip', zip: 'zip', postleitzahl: 'zip', postal: 'zip',
ort: 'city', stadt: 'city', city: 'city',
bundesland: 'state', state: 'state', region: 'state',
land: 'country', country: 'country',
bio: 'bio', uebermich: 'bio', about: 'bio',
}
const rawText = ref('')
const parsed = ref<{ headers: string[]; rows: string[][] } | null>(null)
const mapping = ref<Record<string, number>>({})
const uniqueField = ref('')
const importing = ref(false)
const result = ref<{ created: number; updated: number; skipped: number; errors: { row: number; message: string }[] } | null>(null)
const error = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
function parseCsv(text: string): { headers: string[]; rows: string[][] } | null {
const t = text.replace(/\r\n?/g, '\n').trim()
if (!t) return null
const firstLine = t.split('\n')[0]
const delim = firstLine.split(';').length > firstLine.split(',').length ? ';' : ','
const rows: string[][] = []
let field = ''
let row: string[] = []
let inQ = false
for (let i = 0; i < t.length; i++) {
const ch = t[i]
if (inQ) {
if (ch === '"') {
if (t[i + 1] === '"') { field += '"'; i++ } else inQ = false
} else field += ch
} else if (ch === '"') inQ = true
else if (ch === delim) { row.push(field); field = '' }
else if (ch === '\n') { row.push(field); rows.push(row); row = []; field = '' }
else field += ch
}
row.push(field)
rows.push(row)
const headers = (rows.shift() ?? []).map((h) => h.trim())
return { headers, rows: rows.filter((r) => r.some((c) => c.trim() !== '')) }
}
function norm(s: string) {
return s.toLowerCase().replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] ?? c)).replace(/[^a-z0-9]/g, '')
}
function doParse() {
error.value = ''
result.value = null
const p = parseCsv(rawText.value)
if (!p || !p.headers.length || !p.rows.length) {
error.value = 'Keine Daten erkannt. Erwartet: Kopfzeile + mindestens eine Datenzeile.'
return
}
parsed.value = p
// Auto-Mapping per Header-Namen
const map: Record<string, number> = {}
for (const t of TARGETS) map[t.key] = -1
p.headers.forEach((h, idx) => {
const target = SYNONYMS[norm(h)]
if (target && map[target] === -1) map[target] = idx
})
mapping.value = map
uniqueField.value = map.email >= 0 ? 'email' : ''
}
function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => { rawText.value = String(reader.result ?? ''); doParse() }
reader.readAsText(file)
}
const mappedTargets = computed(() => TARGETS.filter((t) => mapping.value[t.key] >= 0))
const uniqueOptions = computed(() => mappedTargets.value.filter((t) => UNIQUE_KEYS.includes(t.key)))
function rowToObject(r: string[]): Record<string, string> {
const o: Record<string, string> = {}
for (const t of TARGETS) {
const idx = mapping.value[t.key]
if (idx >= 0) o[t.key] = (r[idx] ?? '').trim()
}
return o
}
const preview = computed(() => {
if (!parsed.value?.rows.length) return []
const obj = rowToObject(parsed.value.rows[0])
return mappedTargets.value
.map((t) => ({ label: t.label, value: obj[t.key] ?? '' }))
.filter((x) => x.value !== '')
})
const canImport = computed(() => mapping.value.firstName >= 0 && mapping.value.lastName >= 0)
async function runImport() {
if (!parsed.value) return
importing.value = true
error.value = ''
try {
const rows = parsed.value.rows.map(rowToObject)
const { data } = await client.post('/employees/import', {
rows,
uniqueField: uniqueField.value || null,
})
result.value = data
emit('done')
} catch (e: unknown) {
const ex = e as { response?: { data?: { message?: string; detail?: string } } }
error.value = ex.response?.data?.message ?? ex.response?.data?.detail ?? 'Import fehlgeschlagen.'
} finally {
importing.value = false
}
}
function reset() {
parsed.value = null
result.value = null
rawText.value = ''
}
</script>
<template>
<Modal wide title="Mitarbeiter importieren" @close="emit('close')">
<!-- Schritt 1: Daten einlesen -->
<div v-if="!parsed">
<p class="muted" style="margin-top:0">
CSV-Datei hochladen oder Daten einfügen (Kopfzeile mit Spaltennamen, Trennzeichen Komma oder Semikolon).
</p>
<div class="field">
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFile" />
<button class="btn btn-soft btn-sm" @click="fileInput?.click()">CSV-Datei wählen</button>
</div>
<div class="field">
<label>oder einfügen</label>
<textarea class="input mono" rows="8" v-model="rawText"
placeholder="Vorname;Nachname;E-Mail;Telefon;Straße;PLZ;Ort&#10;Erika;Mustermann;erika@muster.de;+49 30 1;Hauptstr. 1;10115;Berlin"></textarea>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button class="btn btn-ghost" @click="emit('close')">Abbrechen</button>
<button class="btn btn-primary" :disabled="!rawText.trim()" @click="doParse">Weiter</button>
</div>
</div>
<!-- Schritt 2: Mapping + Vorschau -->
<div v-else-if="!result">
<p class="muted" style="margin-top:0">{{ parsed.rows.length }} Datensätze erkannt. Ordnen Sie die Spalten zu.</p>
<div class="map-grid">
<div v-for="t in TARGETS" :key="t.key" class="map-row">
<span class="map-label">{{ t.label }}</span>
<select class="input" v-model.number="mapping[t.key]">
<option :value="-1"> nicht importieren </option>
<option v-for="(h, i) in parsed.headers" :key="i" :value="i">{{ h || ('Spalte ' + (i + 1)) }}</option>
</select>
</div>
</div>
<div class="opts">
<div class="field">
<label>Abgleich-Feld (Aktualisierung)</label>
<select class="input" v-model="uniqueField">
<option value=""> immer neu anlegen </option>
<option v-for="t in uniqueOptions" :key="t.key" :value="t.key">{{ t.label }}</option>
</select>
<p class="muted small">Vorhandene Mitarbeiter mit gleichem Wert werden aktualisiert statt neu angelegt.</p>
</div>
</div>
<div class="preview">
<div class="nfc__label">Vorschau (erster Datensatz)</div>
<div v-if="preview.length" class="pv-list">
<div v-for="p in preview" :key="p.label" class="pv-row"><span>{{ p.label }}</span><strong>{{ p.value }}</strong></div>
</div>
<p v-else class="muted small">Keine Felder zugeordnet.</p>
</div>
<p v-if="!canImport" class="warn small">Vorname und Nachname müssen zugeordnet sein.</p>
<p v-if="error" class="error">{{ error }}</p>
<div class="actions">
<button class="btn btn-ghost" @click="reset">Zurück</button>
<button class="btn btn-primary" :disabled="!canImport || importing" @click="runImport">
{{ importing ? 'Importiere…' : `${parsed.rows.length} importieren` }}
</button>
</div>
</div>
<!-- Schritt 3: Ergebnis -->
<div v-else>
<div class="result">
<div class="rstat"><strong>{{ result.created }}</strong><span>angelegt</span></div>
<div class="rstat"><strong>{{ result.updated }}</strong><span>aktualisiert</span></div>
<div class="rstat"><strong>{{ result.skipped }}</strong><span>übersprungen</span></div>
</div>
<div v-if="result.errors.length" class="errlist">
<div class="nfc__label">Hinweise</div>
<div v-for="(er, i) in result.errors" :key="i" class="small">Zeile {{ er.row }}: {{ er.message }}</div>
</div>
<div class="actions">
<button class="btn btn-ghost" @click="reset">Weiterer Import</button>
<button class="btn btn-primary" @click="emit('close')">Fertig</button>
</div>
</div>
</Modal>
</template>
<style scoped>
.field { margin-bottom: .9rem; }
.mono { font-family: ui-monospace, monospace; font-size: .82rem; }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.2rem; }
.error { color: var(--danger); font-size: .88rem; }
.warn { color: var(--psc-orange-dark); }
.small { font-size: .8rem; }
.map-grid { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem .9rem; }
.map-row { display: grid; grid-template-columns: 9rem 1fr; align-items: center; gap: .5rem; min-width: 0; }
.map-label { font-size: .85rem; font-weight: 600; color: var(--text); }
.opts { margin: 1.1rem 0; }
.opts .muted { margin: .3rem 0 0; }
.preview { background: #f7f7f8; border: 1px solid var(--line); border-radius: var(--radius-sm); padding: .8rem .9rem; }
.nfc__label { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); font-weight: 700; margin-bottom: .5rem; }
.pv-list { display: grid; gap: .3rem; }
.pv-row { display: flex; justify-content: space-between; gap: 1rem; font-size: .88rem; }
.pv-row span { color: var(--muted); }
.pv-row strong { text-align: right; word-break: break-word; }
.result { display: flex; gap: 1rem; margin-bottom: 1rem; }
.rstat { flex: 1; text-align: center; background: var(--psc-orange-soft-2); border: 1px solid var(--psc-border); border-radius: var(--radius-sm); padding: 1rem; }
.rstat strong { display: block; font-size: 1.8rem; color: var(--dark); }
.rstat span { color: var(--muted); font-size: .82rem; }
.errlist { background: #fff7f6; border: 1px solid #f4d4d0; border-radius: var(--radius-sm); padding: .7rem .9rem; margin-bottom: 1rem; max-height: 160px; overflow: auto; }
@media (max-width: 640px) { .map-grid { grid-template-columns: 1fr; } }
</style>

View File

@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import PhoneInput from '@/components/PhoneInput.vue' import PhoneInput from '@/components/PhoneInput.vue'
import CountrySelect from '@/components/CountrySelect.vue' import CountrySelect from '@/components/CountrySelect.vue'
import EmployeeImport from '@/components/EmployeeImport.vue'
const GROUP_LABEL: Record<string, string> = { const GROUP_LABEL: Record<string, string> = {
platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin', platform_admin: 'Plattform-Admin', reseller_admin: 'Reseller-Admin',
@ -142,6 +143,9 @@ async function removeLogin(e: Employee) {
editing.value = employees.value.find((x) => x.id === e.id) ?? null editing.value = employees.value.find((x) => x.id === e.id) ?? null
} }
// --- Import ---
const showImport = ref(false)
// --- Anlegen / Bearbeiten --- // --- Anlegen / Bearbeiten ---
const showForm = ref(false) const showForm = ref(false)
const saving = ref(false) const saving = ref(false)
@ -341,7 +345,10 @@ onMounted(load)
<h1>Mitarbeiter</h1> <h1>Mitarbeiter</h1>
<p class="muted">{{ portalMode ? 'Alle einloggbaren Mitarbeiter der Plattform' : 'Profile als Single Source of Truth für alle Kanäle' }}</p> <p class="muted">{{ portalMode ? 'Alle einloggbaren Mitarbeiter der Plattform' : 'Profile als Single Source of Truth für alle Kanäle' }}</p>
</div> </div>
<button v-if="!portalMode" class="btn btn-primary" @click="openCreate">+ Mitarbeiter hinzufügen</button> <div v-if="!portalMode" class="head-actions">
<button class="btn btn-soft" @click="showImport = true">Importieren</button>
<button class="btn btn-primary" @click="openCreate">+ Mitarbeiter hinzufügen</button>
</div>
</div> </div>
<div class="card"> <div class="card">
@ -550,12 +557,15 @@ onMounted(load)
</div> </div>
</form> </form>
</Modal> </Modal>
<EmployeeImport v-if="showImport" @close="showImport = false" @done="load" />
</section> </section>
</template> </template>
<style scoped> <style scoped>
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; } .page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.4rem; }
.page-head .muted { margin: .2rem 0 0; } .page-head .muted { margin: .2rem 0 0; }
.head-actions { display: flex; gap: .6rem; flex-shrink: 0; }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.2rem; border-bottom: 1px solid var(--line); } .toolbar { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem 1.2rem; border-bottom: 1px solid var(--line); }
.search { flex: 1; max-width: 420px; } .search { flex: 1; max-width: 420px; }
.tbl { width: 100%; border-collapse: collapse; } .tbl { width: 100%; border-collapse: collapse; }