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:
parent
e60871fcd3
commit
b0396bcab8
166
backend/src/Controller/EmployeeImportController.php
Normal file
166
backend/src/Controller/EmployeeImportController.php
Normal 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, '-');
|
||||||
|
}
|
||||||
|
}
|
||||||
286
frontend/src/components/EmployeeImport.vue
Normal file
286
frontend/src/components/EmployeeImport.vue
Normal 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 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>
|
||||||
@ -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; }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user