Import: Privatadresse, Excel-Support, Beispiel-Vorlagen

- Backend: Privatadress-Felder (privateStreet … privateCountry) → addressPrivate,
  Adress-Merge je Block generalisiert
- Frontend: Excel-Parsing (SheetJS/xlsx 0.20.3 vom CDN, gepatcht), private
  Zielfelder + Synonyme, Beispiel-CSV und Beispiel-Excel als Download
- Datei-Upload akzeptiert CSV & XLSX/XLS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Thomas Peterson 2026-06-09 20:16:15 +02:00
parent b0396bcab8
commit 456decb195
4 changed files with 143 additions and 25 deletions

View File

@ -27,6 +27,11 @@ final class EmployeeImportController
]; ];
/** Felder der Geschäftsadresse. */ /** Felder der Geschäftsadresse. */
private const ADDRESS = ['street', 'houseNumber', 'addressLine2', 'zip', 'city', 'state', 'country']; private const ADDRESS = ['street', 'houseNumber', 'addressLine2', 'zip', 'city', 'state', 'country'];
/** Felder der Privatadresse (Import-Keys → Adress-Teilfeld). */
private const ADDRESS_PRIVATE = [
'privateStreet' => 'street', 'privateHouseNumber' => 'houseNumber', 'privateAddressLine2' => 'addressLine2',
'privateZip' => 'zip', 'privateCity' => 'city', 'privateState' => 'state', 'privateCountry' => 'country',
];
/** Felder, die als Unique-Schlüssel zum Abgleich erlaubt sind. */ /** Felder, die als Unique-Schlüssel zum Abgleich erlaubt sind. */
private const UNIQUE_ALLOWED = ['email', 'emailPrivate', 'phone', 'mobile', 'slug']; private const UNIQUE_ALLOWED = ['email', 'emailPrivate', 'phone', 'mobile', 'slug'];
@ -103,7 +108,7 @@ final class EmployeeImportController
private function extract(array $row): array private function extract(array $row): array
{ {
$out = []; $out = [];
foreach ([...self::SCALAR, ...self::ADDRESS] as $key) { foreach ([...self::SCALAR, ...self::ADDRESS, ...array_keys(self::ADDRESS_PRIVATE)] as $key) {
if (\array_key_exists($key, $row)) { if (\array_key_exists($key, $row)) {
$out[$key] = trim((string) $row[$key]); $out[$key] = trim((string) $row[$key]);
} }
@ -129,17 +134,34 @@ final class EmployeeImportController
} }
// Geschäftsadresse: vorhandene Werte beibehalten, neue überschreiben // Geschäftsadresse: vorhandene Werte beibehalten, neue überschreiben
$addr = $isNew ? [] : ($e->getAddressBusiness() ?? []); $bizMap = array_combine(self::ADDRESS, self::ADDRESS);
if (null !== ($biz = $this->mergeAddress($isNew ? [] : ($e->getAddressBusiness() ?? []), $v, $bizMap))) {
$e->setAddressBusiness($biz);
}
// Privatadresse (Import-Keys mit private-Präfix)
if (null !== ($priv = $this->mergeAddress($isNew ? [] : ($e->getAddressPrivate() ?? []), $v, self::ADDRESS_PRIVATE))) {
$e->setAddressPrivate($priv);
}
}
/**
* @param array<string, mixed> $existing
* @param array<string, string> $v
* @param array<string, string> $map Import-Key Adress-Teilfeld
*
* @return array<string, string>|null null = nichts zu setzen
*/
private function mergeAddress(array $existing, array $v, array $map): ?array
{
$touched = false; $touched = false;
foreach (self::ADDRESS as $key) { foreach ($map as $importKey => $addrKey) {
if (\array_key_exists($key, $v) && '' !== $v[$key]) { if (\array_key_exists($importKey, $v) && '' !== $v[$importKey]) {
$addr[$key] = $v[$key]; $existing[$addrKey] = $v[$importKey];
$touched = true; $touched = true;
} }
} }
if ($touched) {
$e->setAddressBusiness($addr); return $touched ? $existing : null;
}
} }
private function uniqueSlug(Company $company, string $first, string $last): string private function uniqueSlug(Company $company, string $first, string $last): string

View File

@ -12,7 +12,8 @@
"pdfjs-dist": "^6.0.227", "pdfjs-dist": "^6.0.227",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-router": "^4.6.4" "vue-router": "^4.6.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
@ -2032,6 +2033,18 @@
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
},
"node_modules/xlsx": {
"version": "0.20.3",
"resolved": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"integrity": "sha512-oLDq3jw7AcLqKWH2AhCpVTZl8mf6X2YReP+Neh0SJUzV/BdZYjth94tG5toiMB1PPrYtxOCfaoUCkvtuH+3AJA==",
"license": "Apache-2.0",
"bin": {
"xlsx": "bin/xlsx.njs"
},
"engines": {
"node": ">=0.8"
}
} }
} }
} }

View File

@ -13,7 +13,8 @@
"pdfjs-dist": "^6.0.227", "pdfjs-dist": "^6.0.227",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"vue": "^3.5.34", "vue": "^3.5.34",
"vue-router": "^4.6.4" "vue-router": "^4.6.4",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.3", "@types/node": "^24.12.3",

View File

@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import * as XLSX from 'xlsx'
import client from '@/api/client' import client from '@/api/client'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
@ -27,6 +28,13 @@ const TARGETS: Target[] = [
{ key: 'city', label: 'Ort' }, { key: 'city', label: 'Ort' },
{ key: 'state', label: 'Bundesland' }, { key: 'state', label: 'Bundesland' },
{ key: 'country', label: 'Land' }, { key: 'country', label: 'Land' },
{ key: 'privateStreet', label: 'Privat: Straße' },
{ key: 'privateHouseNumber', label: 'Privat: Nr.' },
{ key: 'privateAddressLine2', label: 'Privat: Zusatz' },
{ key: 'privateZip', label: 'Privat: PLZ' },
{ key: 'privateCity', label: 'Privat: Ort' },
{ key: 'privateState', label: 'Privat: Bundesland' },
{ key: 'privateCountry', label: 'Privat: Land' },
{ key: 'bio', label: 'Über mich' }, { key: 'bio', label: 'Über mich' },
] ]
const UNIQUE_KEYS = ['email', 'emailPrivate', 'phone', 'mobile'] const UNIQUE_KEYS = ['email', 'emailPrivate', 'phone', 'mobile']
@ -52,9 +60,33 @@ const SYNONYMS: Record<string, string> = {
ort: 'city', stadt: 'city', city: 'city', ort: 'city', stadt: 'city', city: 'city',
bundesland: 'state', state: 'state', region: 'state', bundesland: 'state', state: 'state', region: 'state',
land: 'country', country: 'country', land: 'country', country: 'country',
privatstrasse: 'privateStreet', privstrasse: 'privateStreet',
privatnr: 'privateHouseNumber', privathausnummer: 'privateHouseNumber',
privatzusatz: 'privateAddressLine2',
privatplz: 'privateZip',
privatort: 'privateCity', privatstadt: 'privateCity',
privatbundesland: 'privateState',
privatland: 'privateCountry',
bio: 'bio', uebermich: 'bio', about: 'bio', bio: 'bio', uebermich: 'bio', about: 'bio',
} }
const SAMPLE_HEADERS = [
'Anrede', 'Titel', 'Vorname', 'Nachname', 'Position', 'Abteilung',
'E-Mail', 'E-Mail privat', 'Telefon', 'Mobil', 'Fax', 'Zentrale', 'Website',
'Straße', 'Nr', 'Adresszusatz', 'PLZ', 'Ort', 'Bundesland', 'Land',
'Privat Straße', 'Privat Nr', 'Privat PLZ', 'Privat Ort', 'Privat Land', 'Über mich',
]
const SAMPLE_ROWS = [
['Frau', 'Dr.', 'Erika', 'Mustermann', 'Geschäftsführerin', 'Leitung',
'erika@muster.de', 'erika@privat.de', '+49 30 1234567', '+49 151 2345678', '+49 30 1234599', '+49 30 1234500', 'https://muster.de',
'Hauptstraße', '1', '', '10115', 'Berlin', 'Berlin', 'DE',
'Waldweg', '5', '14467', 'Potsdam', 'DE', 'Kümmert sich um alles.'],
['Herr', '', 'Max', 'Beispiel', 'Vertrieb', 'Sales',
'max@muster.de', '', '+49 30 7654321', '+49 160 1112223', '', '', '',
'Marktplatz', '12', '', '20095', 'Hamburg', 'Hamburg', 'DE',
'', '', '', '', '', ''],
]
const rawText = ref('') const rawText = ref('')
const parsed = ref<{ headers: string[]; rows: string[][] } | null>(null) const parsed = ref<{ headers: string[]; rows: string[][] } | null>(null)
const mapping = ref<Record<string, number>>({}) const mapping = ref<Record<string, number>>({})
@ -94,19 +126,21 @@ function norm(s: string) {
return s.toLowerCase().replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] ?? c)).replace(/[^a-z0-9]/g, '') return s.toLowerCase().replace(/[äöüß]/g, (c) => ({ ä: 'ae', ö: 'oe', ü: 'ue', ß: 'ss' }[c] ?? c)).replace(/[^a-z0-9]/g, '')
} }
function doParse() { function applyParsed(headers: unknown[], rows: unknown[][]) {
error.value = '' error.value = ''
result.value = null result.value = null
const p = parseCsv(rawText.value) const cleanHeaders = headers.map((h) => String(h ?? '').trim())
if (!p || !p.headers.length || !p.rows.length) { const cleanRows = rows
.map((r) => cleanHeaders.map((_, i) => String(r[i] ?? '')))
.filter((r) => r.some((c) => c.trim() !== ''))
if (!cleanHeaders.length || !cleanRows.length) {
error.value = 'Keine Daten erkannt. Erwartet: Kopfzeile + mindestens eine Datenzeile.' error.value = 'Keine Daten erkannt. Erwartet: Kopfzeile + mindestens eine Datenzeile.'
return return
} }
parsed.value = p parsed.value = { headers: cleanHeaders, rows: cleanRows }
// Auto-Mapping per Header-Namen
const map: Record<string, number> = {} const map: Record<string, number> = {}
for (const t of TARGETS) map[t.key] = -1 for (const t of TARGETS) map[t.key] = -1
p.headers.forEach((h, idx) => { cleanHeaders.forEach((h, idx) => {
const target = SYNONYMS[norm(h)] const target = SYNONYMS[norm(h)]
if (target && map[target] === -1) map[target] = idx if (target && map[target] === -1) map[target] = idx
}) })
@ -114,13 +148,53 @@ function doParse() {
uniqueField.value = map.email >= 0 ? 'email' : '' uniqueField.value = map.email >= 0 ? 'email' : ''
} }
function doParse() {
const p = parseCsv(rawText.value)
if (!p) { error.value = 'Keine Daten erkannt.'; return }
applyParsed(p.headers, p.rows)
}
async function parseXlsx(file: File) {
try {
const wb = XLSX.read(await file.arrayBuffer(), { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
const aoa = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: false, defval: '' })
applyParsed((aoa.shift() ?? []) as unknown[], aoa)
} catch {
error.value = 'Excel-Datei konnte nicht gelesen werden.'
}
}
function onFile(e: Event) { function onFile(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return if (!file) return
const name = file.name.toLowerCase()
if (name.endsWith('.xlsx') || name.endsWith('.xls')) {
parseXlsx(file)
} else {
const reader = new FileReader() const reader = new FileReader()
reader.onload = () => { rawText.value = String(reader.result ?? ''); doParse() } reader.onload = () => { rawText.value = String(reader.result ?? ''); doParse() }
reader.readAsText(file) reader.readAsText(file)
} }
}
function triggerDownload(url: string, name: string) {
const a = document.createElement('a')
a.href = url
a.download = name
a.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
function downloadSampleCsv() {
const csv = [SAMPLE_HEADERS, ...SAMPLE_ROWS].map((r) => r.join(';')).join('\r\n')
triggerDownload(URL.createObjectURL(new Blob(['' + csv], { type: 'text/csv;charset=utf-8' })), 'mitarbeiter-vorlage.csv')
}
function downloadSampleXlsx() {
const ws = XLSX.utils.aoa_to_sheet([SAMPLE_HEADERS, ...SAMPLE_ROWS])
const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'Mitarbeiter')
XLSX.writeFile(wb, 'mitarbeiter-vorlage.xlsx')
}
const mappedTargets = computed(() => TARGETS.filter((t) => mapping.value[t.key] >= 0)) const mappedTargets = computed(() => TARGETS.filter((t) => mapping.value[t.key] >= 0))
const uniqueOptions = computed(() => mappedTargets.value.filter((t) => UNIQUE_KEYS.includes(t.key))) const uniqueOptions = computed(() => mappedTargets.value.filter((t) => UNIQUE_KEYS.includes(t.key)))
@ -176,15 +250,20 @@ function reset() {
<!-- Schritt 1: Daten einlesen --> <!-- Schritt 1: Daten einlesen -->
<div v-if="!parsed"> <div v-if="!parsed">
<p class="muted" style="margin-top:0"> <p class="muted" style="margin-top:0">
CSV-Datei hochladen oder Daten einfügen (Kopfzeile mit Spaltennamen, Trennzeichen Komma oder Semikolon). CSV- oder Excel-Datei hochladen oder CSV-Daten einfügen (Kopfzeile mit Spaltennamen).
</p> </p>
<div class="field"> <div class="sample">
<input ref="fileInput" type="file" accept=".csv,text/csv" hidden @change="onFile" /> <span class="muted small">Vorlage:</span>
<button class="btn btn-soft btn-sm" @click="fileInput?.click()">CSV-Datei wählen</button> <button class="linkbtn" @click="downloadSampleCsv">Beispiel-CSV</button>
<button class="linkbtn" @click="downloadSampleXlsx">Beispiel-Excel</button>
</div> </div>
<div class="field"> <div class="field">
<label>oder einfügen</label> <input ref="fileInput" type="file" accept=".csv,.xlsx,.xls,text/csv" hidden @change="onFile" />
<textarea class="input mono" rows="8" v-model="rawText" <button class="btn btn-soft btn-sm" @click="fileInput?.click()">Datei wählen (CSV / Excel)</button>
</div>
<div class="field">
<label>oder CSV einfügen</label>
<textarea class="input mono" rows="7" 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> 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> </div>
<p v-if="error" class="error">{{ error }}</p> <p v-if="error" class="error">{{ error }}</p>
@ -258,6 +337,9 @@ function reset() {
<style scoped> <style scoped>
.field { margin-bottom: .9rem; } .field { margin-bottom: .9rem; }
.mono { font-family: ui-monospace, monospace; font-size: .82rem; } .mono { font-family: ui-monospace, monospace; font-size: .82rem; }
.sample { display: flex; align-items: center; gap: .8rem; margin-bottom: .9rem; }
.linkbtn { background: none; border: none; padding: 0; color: var(--psc-orange-dark); font-weight: 600; font-size: .85rem; cursor: pointer; text-decoration: underline; font-family: var(--font); }
.linkbtn:hover { color: var(--psc-orange); }
.actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.2rem; } .actions { display: flex; justify-content: flex-end; gap: .6rem; margin-top: 1.2rem; }
.error { color: var(--danger); font-size: .88rem; } .error { color: var(--danger); font-size: .88rem; }
.warn { color: var(--psc-orange-dark); } .warn { color: var(--psc-orange-dark); }