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. */
|
/** 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
|
||||||
|
|||||||
15
frontend/package-lock.json
generated
15
frontend/package-lock.json
generated
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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,12 +148,52 @@ 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 reader = new FileReader()
|
const name = file.name.toLowerCase()
|
||||||
reader.onload = () => { rawText.value = String(reader.result ?? ''); doParse() }
|
if (name.endsWith('.xlsx') || name.endsWith('.xls')) {
|
||||||
reader.readAsText(file)
|
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))
|
const mappedTargets = computed(() => TARGETS.filter((t) => mapping.value[t.key] >= 0))
|
||||||
@ -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 Erika;Mustermann;erika@muster.de;+49 30 1;Hauptstr. 1;10115;Berlin"></textarea>
|
placeholder="Vorname;Nachname;E-Mail;Telefon;Straße;PLZ;Ort 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); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user