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:
parent
b0396bcab8
commit
456decb195
@ -27,6 +27,11 @@ final class EmployeeImportController
|
||||
];
|
||||
/** Felder der Geschäftsadresse. */
|
||||
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. */
|
||||
private const UNIQUE_ALLOWED = ['email', 'emailPrivate', 'phone', 'mobile', 'slug'];
|
||||
|
||||
@ -103,7 +108,7 @@ final class EmployeeImportController
|
||||
private function extract(array $row): array
|
||||
{
|
||||
$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)) {
|
||||
$out[$key] = trim((string) $row[$key]);
|
||||
}
|
||||
@ -129,17 +134,34 @@ final class EmployeeImportController
|
||||
}
|
||||
|
||||
// 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;
|
||||
foreach (self::ADDRESS as $key) {
|
||||
if (\array_key_exists($key, $v) && '' !== $v[$key]) {
|
||||
$addr[$key] = $v[$key];
|
||||
foreach ($map as $importKey => $addrKey) {
|
||||
if (\array_key_exists($importKey, $v) && '' !== $v[$importKey]) {
|
||||
$existing[$addrKey] = $v[$importKey];
|
||||
$touched = true;
|
||||
}
|
||||
}
|
||||
if ($touched) {
|
||||
$e->setAddressBusiness($addr);
|
||||
}
|
||||
|
||||
return $touched ? $existing : null;
|
||||
}
|
||||
|
||||
private function uniqueSlug(Company $company, string $first, string $last): string
|
||||
|
||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@ -12,7 +12,8 @@
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"pinia": "^3.0.4",
|
||||
"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": {
|
||||
"@types/node": "^24.12.3",
|
||||
@ -2032,6 +2033,18 @@
|
||||
"peerDependencies": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,7 +13,8 @@
|
||||
"pdfjs-dist": "^6.0.227",
|
||||
"pinia": "^3.0.4",
|
||||
"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": {
|
||||
"@types/node": "^24.12.3",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import * as XLSX from 'xlsx'
|
||||
import client from '@/api/client'
|
||||
import Modal from '@/components/Modal.vue'
|
||||
|
||||
@ -27,6 +28,13 @@ const TARGETS: Target[] = [
|
||||
{ key: 'city', label: 'Ort' },
|
||||
{ key: 'state', label: 'Bundesland' },
|
||||
{ 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' },
|
||||
]
|
||||
const UNIQUE_KEYS = ['email', 'emailPrivate', 'phone', 'mobile']
|
||||
@ -52,9 +60,33 @@ const SYNONYMS: Record<string, string> = {
|
||||
ort: 'city', stadt: 'city', city: 'city',
|
||||
bundesland: 'state', state: 'state', region: 'state',
|
||||
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',
|
||||
}
|
||||
|
||||
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 parsed = ref<{ headers: string[]; rows: string[][] } | null>(null)
|
||||
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, '')
|
||||
}
|
||||
|
||||
function doParse() {
|
||||
function applyParsed(headers: unknown[], rows: unknown[][]) {
|
||||
error.value = ''
|
||||
result.value = null
|
||||
const p = parseCsv(rawText.value)
|
||||
if (!p || !p.headers.length || !p.rows.length) {
|
||||
const cleanHeaders = headers.map((h) => String(h ?? '').trim())
|
||||
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.'
|
||||
return
|
||||
}
|
||||
parsed.value = p
|
||||
// Auto-Mapping per Header-Namen
|
||||
parsed.value = { headers: cleanHeaders, rows: cleanRows }
|
||||
const map: Record<string, number> = {}
|
||||
for (const t of TARGETS) map[t.key] = -1
|
||||
p.headers.forEach((h, idx) => {
|
||||
cleanHeaders.forEach((h, idx) => {
|
||||
const target = SYNONYMS[norm(h)]
|
||||
if (target && map[target] === -1) map[target] = idx
|
||||
})
|
||||
@ -114,12 +148,52 @@ function doParse() {
|
||||
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) {
|
||||
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 name = file.name.toLowerCase()
|
||||
if (name.endsWith('.xlsx') || name.endsWith('.xls')) {
|
||||
parseXlsx(file)
|
||||
} else {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => { rawText.value = String(reader.result ?? ''); doParse() }
|
||||
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))
|
||||
@ -176,15 +250,20 @@ function reset() {
|
||||
<!-- 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).
|
||||
CSV- oder Excel-Datei hochladen oder CSV-Daten einfügen (Kopfzeile mit Spaltennamen).
|
||||
</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 class="sample">
|
||||
<span class="muted small">Vorlage:</span>
|
||||
<button class="linkbtn" @click="downloadSampleCsv">Beispiel-CSV</button>
|
||||
<button class="linkbtn" @click="downloadSampleXlsx">Beispiel-Excel</button>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>oder einfügen</label>
|
||||
<textarea class="input mono" rows="8" v-model="rawText"
|
||||
<input ref="fileInput" type="file" accept=".csv,.xlsx,.xls,text/csv" hidden @change="onFile" />
|
||||
<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 Erika;Mustermann;erika@muster.de;+49 30 1;Hauptstr. 1;10115;Berlin"></textarea>
|
||||
</div>
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
@ -258,6 +337,9 @@ function reset() {
|
||||
<style scoped>
|
||||
.field { margin-bottom: .9rem; }
|
||||
.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; }
|
||||
.error { color: var(--danger); font-size: .88rem; }
|
||||
.warn { color: var(--psc-orange-dark); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user