Manual generation, LLM notes with company address, and static attachments
- Static extra attachments: upload files (e.g. Zeugnisse) once on the Vorlagen
page; they are attached to every generated application and listed under
"Anlagen" in the cover letter (via multer upload + basis_anhaenge table).
- Import no longer auto-generates: an imported job is saved as a draft
("nicht_gestartet"); generation is triggered manually on the application page.
- Per-application "LLM-Notizen" field: free text (company address, contact
person, extra context) that is fed to the model at generation time. Saving the
notes and (re)generating happens in one action.
- Cover letter recipient block is now a structured empfaenger (firma, address,
city, contact person) the model fills from the job + notes; the salutation
adapts to a named contact. Falls back to the imported company + city.
- Raise default OLLAMA_TIMEOUT_MS to 300s for slower cloud models.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+107
-19
@@ -10,7 +10,7 @@ const { jsPDF } = require('jspdf');
|
||||
// Ollama Cloud API (https://ollama.com). Override host/model via env if needed.
|
||||
const OLLAMA_HOST = (process.env.OLLAMA_HOST || 'https://ollama.com').replace(/\/+$/, '');
|
||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'gpt-oss:120b';
|
||||
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 180000);
|
||||
const OLLAMA_TIMEOUT_MS = Number(process.env.OLLAMA_TIMEOUT_MS || 300000);
|
||||
|
||||
// ===========================================================================
|
||||
// 1. LLM call — produce tailored TEXT for the template
|
||||
@@ -47,12 +47,22 @@ const OUTPUT_SCHEMA = {
|
||||
anschreiben: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
empfaenger: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
firma: { type: 'string' },
|
||||
adresse: { type: 'string' },
|
||||
ort: { type: 'string' },
|
||||
ansprechpartner: { type: 'string' },
|
||||
},
|
||||
required: ['firma', 'adresse', 'ort', 'ansprechpartner'],
|
||||
},
|
||||
betreff: { type: 'string' },
|
||||
anrede: { type: 'string' },
|
||||
absaetze: { type: 'array', items: { type: 'string' } },
|
||||
gruss: { type: 'string' },
|
||||
},
|
||||
required: ['betreff', 'anrede', 'absaetze', 'gruss'],
|
||||
required: ['empfaenger', 'betreff', 'anrede', 'absaetze', 'gruss'],
|
||||
},
|
||||
lebenslauf: {
|
||||
type: 'object',
|
||||
@@ -91,7 +101,7 @@ const OUTPUT_SCHEMA = {
|
||||
required: ['headline', 'kontakt', 'anschreiben', 'lebenslauf'],
|
||||
};
|
||||
|
||||
async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
async function generateTailoredTexts({ job, basisDokumente, settings, llmNotizen = '' }) {
|
||||
const apiKey = process.env.OLLAMA_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
@@ -140,6 +150,7 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
` "headline": "Kurze Berufsbezeichnung (max. 5 Wörter)",\n` +
|
||||
` "kontakt": { "email": "", "telefon": "", "ort": "", "webseite": "", "geburtsdatum": "", "fuehrerschein": "" },\n` +
|
||||
` "anschreiben": {\n` +
|
||||
` "empfaenger": { "firma": "Unternehmen", "adresse": "Straße Hausnr. (falls bekannt, sonst leer)", "ort": "PLZ Ort", "ansprechpartner": "" },\n` +
|
||||
` "betreff": "Bewerbung als …",\n` +
|
||||
` "anrede": "Sehr geehrte Damen und Herren,",\n` +
|
||||
` "absaetze": ["Absatz 1", "Absatz 2", "Absatz 3"],\n` +
|
||||
@@ -165,10 +176,17 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
` }\n` +
|
||||
`}`;
|
||||
|
||||
const notizenText = (llmNotizen && llmNotizen.trim())
|
||||
? `# Zusätzliche Notizen des Bewerbers (WICHTIG — nutze diese Angaben aktiv: z. B. vollständige ` +
|
||||
`Firmenanschrift, Ansprechpartner, besondere Hinweise zur Bewerbung oder zum Unternehmen)\n` +
|
||||
`${llmNotizen.trim()}\n\n`
|
||||
: '';
|
||||
|
||||
const userPrompt =
|
||||
`# Bewerberdaten\n${bewerber}\n\n` +
|
||||
`# Basis-Unterlagen des Bewerbers (Faktengrundlage — NUR diese Fakten verwenden)\n${basisText}\n\n` +
|
||||
`# Zielstelle\n${stelleText}\n\n` +
|
||||
notizenText +
|
||||
`# Aufgabe\n` +
|
||||
`Erzeuge die TEXTE für ein Anschreiben und einen Lebenslauf, passgenau auf diese ` +
|
||||
`Stelle zugeschnitten. Layout/Design ist bereits vorgegeben — liefere nur die Inhalte. ` +
|
||||
@@ -180,15 +198,26 @@ async function generateTailoredTexts({ job, basisDokumente, settings }) {
|
||||
`- headline: kurze Berufsbezeichnung des Bewerbers, ggf. auf die Zielstelle zugeschnitten.\n` +
|
||||
`- kontakt: E-Mail, Telefon, Ort, Webseite, Geburtsdatum, Führerschein NUR übernehmen, ` +
|
||||
`wenn in den Basis-Unterlagen vorhanden; sonst leerer String. Nichts erfinden.\n` +
|
||||
`- anschreiben.empfaenger: Empfängerblock. firma = Unternehmen; adresse = Straße + Hausnr. ` +
|
||||
`(nur falls aus Stellenanzeige oder Notizen bekannt, sonst leer); ort = "PLZ Ort"; ` +
|
||||
`ansprechpartner = konkrete Kontaktperson (nur falls bekannt, sonst leer). Nutze eine in den ` +
|
||||
`Notizen angegebene vollständige Firmenanschrift und einen genannten Ansprechpartner. ` +
|
||||
`Ist ein Ansprechpartner bekannt, passe auch die Anrede an (z. B. "Sehr geehrte Frau …,").\n` +
|
||||
`- anschreiben.absaetze: 3–4 kurze, überzeugende Absätze (kein Adressblock, kein Datum, ` +
|
||||
`keine Grußformel hier). Sachlich und positiv, in Aktiv-Sätzen, ohne Floskeln und ` +
|
||||
`Weichspüler ("Ich würde gerne …"). KEINE Gehaltsvorstellungen, KEINE Kündigungsgründe, ` +
|
||||
`KEINE Erwähnung von Lücken/Arbeitslosigkeit, keine Selbstzweifel. Keine Platzhalter — echte Angaben nutzen.\n` +
|
||||
`KEINE Erwähnung von Lücken/Arbeitslosigkeit, keine Selbstzweifel. Keine Platzhalter — echte Angaben nutzen. ` +
|
||||
`WICHTIG: Der erste Absatz beginnt mit einem KLEINbuchstaben (die Anrede endet mit Komma, der Satz wird ` +
|
||||
`fortgesetzt), z. B. "mit …" oder "als …" — außer das erste Wort ist ein Substantiv, ein Eigenname oder die ` +
|
||||
`Höflichkeitsform "Ihre/Ihr/Ihnen".\n` +
|
||||
`- berufserfahrung: ALLE Stationen aus den Unterlagen, neueste zuerst. "firma" = "Arbeitgeber, Ort". ` +
|
||||
`Nur die 2–3 jüngsten/relevantesten erhalten EINEN kurzen "beschreibung"-Satz, ältere lässt du leer.\n` +
|
||||
`- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Berufskolleg-Abschluss → ` +
|
||||
`berufsausbildung; schulische Abschlüsse (Realschule, Gymnasium, Fachoberschulreife/Abitur) → schulbildung; ` +
|
||||
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen.\n` +
|
||||
`- Bildung korrekt einordnen: Hochschulstudium → studium; Berufsausbildung/Ausbildungsberuf → ` +
|
||||
`berufsausbildung; schulische Abschlüsse (Mittlere Reife/Fachoberschulreife, Fachhochschulreife, Abitur) → schulbildung; ` +
|
||||
`Zertifikate/Fortbildungen → weiterbildungen. Grundschule weglassen. ` +
|
||||
`WICHTIG: Wenn ein schulischer Abschluss (z. B. Fachhochschulreife) an einem Berufskolleg gemeinsam mit einer ` +
|
||||
`Berufsausbildung erworben wurde, führe diesen schulischen Abschluss ZUSÄTZLICH als eigenen Eintrag unter ` +
|
||||
`schulbildung auf (nicht nur als Zusatz der Berufsausbildung).\n` +
|
||||
`- kenntnisse: max. 12 prägnante Stichworte, relevanteste zuerst.\n` +
|
||||
`- sprachen, hobbys, weiterbildungen: nur falls in den Unterlagen vorhanden.\n\n` +
|
||||
`WICHTIG — keine HR-Red-Flags:\n` +
|
||||
@@ -280,6 +309,34 @@ function toZeitraum(v) {
|
||||
return str(v);
|
||||
}
|
||||
|
||||
// After an Anrede ending in a comma, the body continues the sentence, so its
|
||||
// first word is lowercase — unless it's a noun, proper name or the polite
|
||||
// "Ihr/Ihre/…". We only downcase clearly-safe sentence openers (prepositions,
|
||||
// conjunctions, adverbs, lowercase pronouns) to avoid breaking those cases.
|
||||
const LOWER_OPENERS = new Set([
|
||||
'mit', 'für', 'über', 'auf', 'in', 'bei', 'durch', 'seit', 'nach', 'aus', 'von', 'zu',
|
||||
'im', 'am', 'zum', 'zur', 'vor', 'gegen', 'um', 'ohne', 'trotz', 'wegen', 'aufgrund',
|
||||
'dank', 'während', 'per', 'laut', 'als', 'wie', 'da', 'weil', 'nachdem', 'hiermit',
|
||||
'anbei', 'beiliegend', 'gerne', 'gern', 'sehr', 'bereits', 'schon', 'momentan',
|
||||
'derzeit', 'aktuell', 'nun', 'mittlerweile', 'inzwischen', 'ich', 'mein', 'meine',
|
||||
'meinen', 'meiner', 'meinem', 'meines',
|
||||
]);
|
||||
|
||||
function fixAnredeContinuation(anrede, absaetze) {
|
||||
if (!absaetze.length || !anrede.trim().endsWith(',')) return absaetze;
|
||||
const m = absaetze[0].match(/^(\s*)([A-Za-zÄÖÜäöüß]+)([\s\S]*)$/);
|
||||
if (!m) return absaetze;
|
||||
const [, lead, word, rest] = m;
|
||||
const first = word[0];
|
||||
const isUpper = (first >= 'A' && first <= 'Z') || 'ÄÖÜ'.includes(first);
|
||||
if (isUpper && LOWER_OPENERS.has(word.toLowerCase())) {
|
||||
const out = absaetze.slice();
|
||||
out[0] = lead + first.toLowerCase() + word.slice(1) + rest;
|
||||
return out;
|
||||
}
|
||||
return absaetze;
|
||||
}
|
||||
|
||||
// Defensive: never let an HR red-flag phrase reach the PDF, even if the model
|
||||
// ignores the instruction.
|
||||
const RED_FLAG = /(nicht\s*beendet|abgebrochen|abbruch|ohne\s*abschluss|arbeits(los|suchend)|gek(ü|ue)ndigt|entlassen)/i;
|
||||
@@ -320,12 +377,23 @@ function normalizeResult(parsed) {
|
||||
geburtsdatum: str(pick(k, ['geburtsdatum', 'geboren', 'birthdate', 'dob', 'geburt'])),
|
||||
fuehrerschein: str(pick(k, ['fuehrerschein', 'führerschein', 'driving_license', 'license', 'licence', 'fuehrerscheinklasse'])),
|
||||
},
|
||||
anschreiben: {
|
||||
betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
|
||||
anrede: str(pick(a, ['anrede', 'salutation'])),
|
||||
absaetze: pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean),
|
||||
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
|
||||
anschreiben: (() => {
|
||||
const anrede = str(pick(a, ['anrede', 'salutation']));
|
||||
const absaetze = pickArray(a, ['absaetze', 'absätze', 'paragraphs', 'text', 'inhalt']).map(str).filter(Boolean);
|
||||
const e = a.empfaenger || a.recipient || a.empfänger || {};
|
||||
return {
|
||||
empfaenger: {
|
||||
firma: str(pick(e, ['firma', 'unternehmen', 'company', 'name'])),
|
||||
adresse: str(pick(e, ['adresse', 'strasse', 'straße', 'street', 'anschrift'])),
|
||||
ort: str(pick(e, ['ort', 'plz_ort', 'plzort', 'stadt', 'city', 'plz'])),
|
||||
ansprechpartner: str(pick(e, ['ansprechpartner', 'ansprechperson', 'kontaktperson', 'contact'])),
|
||||
},
|
||||
betreff: str(pick(a, ['betreff', 'subject', 'titel'])),
|
||||
anrede,
|
||||
absaetze: fixAnredeContinuation(anrede, absaetze),
|
||||
gruss: str(pick(a, ['gruss', 'gruß', 'grussformel', 'closing', 'schluss'])),
|
||||
};
|
||||
})(),
|
||||
lebenslauf: {
|
||||
berufserfahrung: pickArray(l, ['berufserfahrung', 'erfahrung', 'work_experience', 'experience', 'stationen'])
|
||||
.map((e) => ({
|
||||
@@ -579,12 +647,22 @@ const LC = {
|
||||
hair: [206, 208, 212],
|
||||
};
|
||||
|
||||
// Extract just the town from a possibly-full address ("Feldstraße 76, 45968
|
||||
// Gladbeck" / "45968 Gladbeck" / "Gladbeck" → "Gladbeck").
|
||||
function cityName(header) {
|
||||
let s = header.ort || header.adresse || '';
|
||||
const parts = String(s).split(',').map((p) => p.trim()).filter(Boolean);
|
||||
if (parts.length) s = parts[parts.length - 1];
|
||||
return s.replace(/\b\d{5}\b/g, '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen }) {
|
||||
const x = LET.mx;
|
||||
const right = LET_R;
|
||||
const width = LET_W;
|
||||
const today = new Date().toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
const betreff = letter.betreff || (job.stelle ? `Bewerbung als ${job.stelle}` : 'Bewerbung');
|
||||
const stadt = cityName(header); // just the town for the date line ("Ort, den …")
|
||||
|
||||
// --- Letterhead: name (left) + contact (right), thin rule ---
|
||||
const top = cur.y;
|
||||
@@ -603,13 +681,18 @@ function composeLetter(doc, S, dry, cur, { letter, header, job, anlagen }) {
|
||||
rule(doc, S, dry, cur.y, x, right, LC.hair, 0.3);
|
||||
cur.y += 13 * S;
|
||||
|
||||
// --- Recipient (Anschriftfeld) ---
|
||||
if (job.firma) write(doc, S, dry, cur, job.firma, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (job.ort) write(doc, S, dry, cur, job.ort, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
// --- Recipient (Anschriftfeld) — from the LLM's empfaenger, falling back to job ---
|
||||
const emp = letter.empfaenger || {};
|
||||
const empFirma = emp.firma || job.firma || '';
|
||||
const empOrt = emp.ort || job.ort || '';
|
||||
if (empFirma) write(doc, S, dry, cur, empFirma, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (emp.ansprechpartner) write(doc, S, dry, cur, `z. Hd. ${emp.ansprechpartner}`, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (emp.adresse) write(doc, S, dry, cur, emp.adresse, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
if (empOrt) write(doc, S, dry, cur, empOrt, { pt: 10.5, color: LC.ink, x, width, factor: 1.32 });
|
||||
|
||||
// --- Date (right-aligned) ---
|
||||
cur.y += 7 * S;
|
||||
const dateLine = header.ort ? `${header.ort}, den ${today}` : today;
|
||||
const dateLine = stadt ? `${stadt}, den ${today}` : today;
|
||||
write(doc, S, dry, cur, dateLine, { pt: 10, color: LC.ink, x, width, align: 'right', right, factor: 1.2 });
|
||||
|
||||
// --- Subject (bold, no "Betreff:" label) ---
|
||||
@@ -679,8 +762,8 @@ function hasLebenslauf(l) {
|
||||
|| l.schulbildung.length || l.weiterbildungen.length || l.kenntnisse.length);
|
||||
}
|
||||
|
||||
async function generateApplicationDocuments({ job, basisDokumente, settings }) {
|
||||
const data = await generateTailoredTexts({ job, basisDokumente, settings });
|
||||
async function generateApplicationDocuments({ job, basisDokumente, settings, zusatzAnlagen = [], llmNotizen = '' }) {
|
||||
const data = await generateTailoredTexts({ job, basisDokumente, settings, llmNotizen });
|
||||
const headline = data.headline || job.stelle || '';
|
||||
const header = buildHeader(settings, data.kontakt, headline);
|
||||
|
||||
@@ -690,13 +773,18 @@ async function generateApplicationDocuments({ job, basisDokumente, settings }) {
|
||||
|
||||
const documents = [];
|
||||
const cvPresent = hasLebenslauf(data.lebenslauf);
|
||||
// "Anlagen" list for the letter: Lebenslauf first, then any static extras.
|
||||
const anlagen = [
|
||||
...(cvPresent ? ['Lebenslauf'] : []),
|
||||
...zusatzAnlagen.filter(Boolean),
|
||||
];
|
||||
|
||||
if (hasAnschreiben(data.anschreiben)) {
|
||||
documents.push({
|
||||
name: `Anschreiben – ${label}`.trim(),
|
||||
filename: `Anschreiben_${suffix}.pdf`,
|
||||
mime: 'application/pdf',
|
||||
buffer: renderAnschreibenPdf(data.anschreiben, header, job, cvPresent ? ['Lebenslauf'] : []),
|
||||
buffer: renderAnschreibenPdf(data.anschreiben, header, job, anlagen),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Generated
+72
@@ -13,6 +13,7 @@
|
||||
"express": "^4.18.2",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"multer": "^2.2.0",
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -187,6 +188,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/aproba": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
|
||||
@@ -384,6 +391,23 @@
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-from": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -533,6 +557,21 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.0.2",
|
||||
"typedarray": "^0.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/console-control-strings": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
@@ -1790,6 +1829,25 @@
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz",
|
||||
"integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
@@ -2652,6 +2710,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -2849,6 +2915,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/typedarray": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undefsafe": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"express": "^4.18.2",
|
||||
"jspdf": "^2.5.1",
|
||||
"jspdf-autotable": "^3.8.2",
|
||||
"multer": "^2.2.0",
|
||||
"sqlite3": "^5.1.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -2,6 +2,7 @@ const express = require('express');
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
|
||||
// Minimal, dependency-free .env loader: load KEY=VALUE lines from a local
|
||||
// (git-ignored) .env file into process.env without overwriting existing vars.
|
||||
@@ -75,6 +76,25 @@ if (!fs.existsSync(anhaengeDir)) {
|
||||
fs.mkdirSync(anhaengeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Directory for static extra attachments (e.g. Zeugnisse) the user uploads once
|
||||
// and that are sent along with every generated application.
|
||||
const basisAnhaengeDir = path.join(dataDir, 'basis_anhaenge');
|
||||
if (!fs.existsSync(basisAnhaengeDir)) {
|
||||
fs.mkdirSync(basisAnhaengeDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Multipart upload for those static attachments
|
||||
const uploadBasisAnhang = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, basisAnhaengeDir),
|
||||
filename: (req, file, cb) => {
|
||||
const safe = String(file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
cb(null, `${Date.now()}_${safe}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 15 * 1024 * 1024 }, // 15 MB
|
||||
}).single('datei');
|
||||
|
||||
// Database setup
|
||||
const dbPath = path.join(dataDir, 'bewerbungen.db');
|
||||
const db = new sqlite3.Database(dbPath);
|
||||
@@ -147,6 +167,7 @@ async function runGeneration(bewerbungId) {
|
||||
const bewerbung = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [bewerbungId]);
|
||||
if (!bewerbung) return;
|
||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
|
||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||
|
||||
const documents = await generateApplicationDocuments({
|
||||
@@ -159,15 +180,32 @@ async function runGeneration(bewerbungId) {
|
||||
},
|
||||
basisDokumente,
|
||||
settings,
|
||||
// Names of the static attachments so the cover letter can list them under "Anlagen".
|
||||
zusatzAnlagen: basisAnhaenge.map((a) => a.name || a.dateiname),
|
||||
// Free-text notes (company address, contact person, extra context) for the LLM.
|
||||
llmNotizen: bewerbung.llm_notizen || '',
|
||||
});
|
||||
|
||||
for (const doc of documents) {
|
||||
const stored = `${bewerbungId}_${Date.now()}_${doc.filename}`;
|
||||
fs.writeFileSync(path.join(anhaengeDir, stored), doc.buffer);
|
||||
let seq = 0;
|
||||
const storeAnhang = async (name, filename, mime, buffer) => {
|
||||
const stored = `${bewerbungId}_${Date.now()}_${seq++}_${filename}`;
|
||||
fs.writeFileSync(path.join(anhaengeDir, stored), buffer);
|
||||
await dbRun(
|
||||
'INSERT INTO anhaenge (bewerbung_id, name, dateiname, mime, pfad) VALUES (?, ?, ?, ?, ?)',
|
||||
[bewerbungId, doc.name, doc.filename, doc.mime, stored]
|
||||
[bewerbungId, name, filename, mime, stored]
|
||||
);
|
||||
};
|
||||
|
||||
// Generated (AI) documents
|
||||
for (const doc of documents) {
|
||||
await storeAnhang(doc.name, doc.filename, doc.mime, doc.buffer);
|
||||
}
|
||||
|
||||
// Static extra attachments (e.g. Zeugnisse) — copied as-is
|
||||
for (const ba of basisAnhaenge) {
|
||||
const src = path.join(basisAnhaengeDir, ba.pfad);
|
||||
if (!fs.existsSync(src)) continue;
|
||||
await storeAnhang(ba.name || ba.dateiname, ba.dateiname, ba.mime || 'application/octet-stream', fs.readFileSync(src));
|
||||
}
|
||||
|
||||
await dbRun(
|
||||
@@ -214,6 +252,7 @@ function initializeDatabase() {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN quelle_url TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_status TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN generierung_fehler TEXT', () => {
|
||||
db.run('ALTER TABLE bewerbungen ADD COLUMN llm_notizen TEXT', () => {
|
||||
|
||||
// Chronological status changes, each with an optional comment
|
||||
db.run(`
|
||||
@@ -241,6 +280,19 @@ function initializeDatabase() {
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Static extra attachments (e.g. Zeugnisse) attached to every application
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS basis_anhaenge (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
dateiname TEXT NOT NULL,
|
||||
mime TEXT,
|
||||
pfad TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
// Generated attachment files linked to an application
|
||||
db.run(`
|
||||
CREATE TABLE IF NOT EXISTS anhaenge (
|
||||
@@ -296,6 +348,8 @@ function initializeDatabase() {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize and start server
|
||||
@@ -431,7 +485,7 @@ initializeDatabase().then(() => {
|
||||
const result = await dbRun(
|
||||
`INSERT INTO bewerbungen
|
||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung, quelle_url, generierung_status)
|
||||
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'ausstehend')`,
|
||||
VALUES (?, ?, ?, 'Indeed', 'Entwurf', ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||
[datum, firma, stelle, notizen, ort || '', stellenbeschreibung || '', quelle_url || '']
|
||||
);
|
||||
|
||||
@@ -441,14 +495,13 @@ initializeDatabase().then(() => {
|
||||
[result.lastID, datum, 'Entwurf', 'Automatisch über Indeed importiert']
|
||||
);
|
||||
|
||||
// Kick off AI document generation in the background — respond immediately.
|
||||
runGeneration(result.lastID);
|
||||
|
||||
// Note: generation is NOT started automatically — the user reviews the draft,
|
||||
// adds LLM notes if needed, and triggers generation on the application page.
|
||||
res.json({
|
||||
success: true,
|
||||
id: result.lastID,
|
||||
url: `/bewerbung/${result.lastID}`,
|
||||
message: 'Bewerbung als Entwurf angelegt. Die Unterlagen werden nun generiert.',
|
||||
message: 'Bewerbung als Entwurf angelegt. Unterlagen können auf der Bewerbungsseite generiert werden.',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error importing job:', error);
|
||||
@@ -692,8 +745,10 @@ initializeDatabase().then(() => {
|
||||
app.get('/vorlagen', async (req, res) => {
|
||||
try {
|
||||
const basisDokumente = await dbAll('SELECT * FROM basis_dokumente ORDER BY id ASC');
|
||||
const basisAnhaenge = await dbAll('SELECT * FROM basis_anhaenge ORDER BY id ASC');
|
||||
res.render('vorlagen', {
|
||||
basisDokumente,
|
||||
basisAnhaenge,
|
||||
basisTypOptions: BASIS_TYP_OPTIONS,
|
||||
hasApiKey: Boolean(process.env.OLLAMA_API_KEY),
|
||||
hideSettings: true,
|
||||
@@ -748,6 +803,63 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// ----- Static extra attachments (Zeugnisse etc.) -----
|
||||
|
||||
// Upload an attachment
|
||||
app.post('/anlagen', (req, res) => {
|
||||
uploadBasisAnhang(req, res, async (err) => {
|
||||
try {
|
||||
if (err) {
|
||||
console.error('Upload error:', err.message);
|
||||
return res.redirect('/vorlagen');
|
||||
}
|
||||
if (req.file) {
|
||||
const original = req.file.originalname || req.file.filename;
|
||||
const name = (req.body.name && req.body.name.trim())
|
||||
? sanitizeInput(req.body.name.trim())
|
||||
: sanitizeInput(original.replace(/\.[^.]+$/, ''));
|
||||
await dbRun(
|
||||
'INSERT INTO basis_anhaenge (name, dateiname, mime, pfad) VALUES (?, ?, ?, ?)',
|
||||
[name, sanitizeInput(original), req.file.mimetype || 'application/octet-stream', req.file.filename]
|
||||
);
|
||||
}
|
||||
res.redirect('/vorlagen');
|
||||
} catch (error) {
|
||||
console.error('Error saving attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Download a static attachment
|
||||
app.get('/anlagen/:id/download', async (req, res) => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
||||
if (!a) return res.status(404).send('Anlage nicht gefunden');
|
||||
const filePath = path.join(basisAnhaengeDir, a.pfad);
|
||||
if (!fs.existsSync(filePath)) return res.status(404).send('Datei nicht gefunden');
|
||||
res.download(filePath, a.dateiname);
|
||||
} catch (error) {
|
||||
console.error('Error downloading attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a static attachment
|
||||
app.post('/anlagen/:id/delete', async (req, res) => {
|
||||
try {
|
||||
const a = await dbGet('SELECT * FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
||||
if (a) {
|
||||
fs.promises.unlink(path.join(basisAnhaengeDir, a.pfad)).catch(() => {});
|
||||
await dbRun('DELETE FROM basis_anhaenge WHERE id = ?', [req.params.id]);
|
||||
}
|
||||
res.redirect('/vorlagen');
|
||||
} catch (error) {
|
||||
console.error('Error deleting attachment:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
// Download a generated attachment
|
||||
app.get('/anhaenge/:id/download', async (req, res) => {
|
||||
try {
|
||||
@@ -779,13 +891,19 @@ initializeDatabase().then(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Re-run the AI generation for an application (removes old generated files first)
|
||||
app.post('/bewerbung/:id/regenerate', async (req, res) => {
|
||||
// Start (or re-run) the AI generation for an application. Saves the LLM notes
|
||||
// first, removes any previously generated attachments, then generates.
|
||||
app.post('/bewerbung/:id/generieren', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
|
||||
if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
|
||||
|
||||
// Persist the LLM notes the user provided (used as context during generation)
|
||||
if (typeof req.body.llm_notizen !== 'undefined') {
|
||||
await dbRun('UPDATE bewerbungen SET llm_notizen = ? WHERE id = ?', [req.body.llm_notizen || '', id]);
|
||||
}
|
||||
|
||||
const alte = await dbAll('SELECT * FROM anhaenge WHERE bewerbung_id = ?', [id]);
|
||||
for (const a of alte) {
|
||||
fs.promises.unlink(path.join(anhaengeDir, a.pfad)).catch(() => {});
|
||||
@@ -796,7 +914,7 @@ initializeDatabase().then(() => {
|
||||
runGeneration(id);
|
||||
res.redirect('/bewerbung/' + id);
|
||||
} catch (error) {
|
||||
console.error('Error regenerating documents:', error);
|
||||
console.error('Error generating documents:', error);
|
||||
res.status(500).send('Serverfehler');
|
||||
}
|
||||
});
|
||||
|
||||
+33
-11
@@ -101,21 +101,18 @@
|
||||
data-status="<%= genStatus || '' %>">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Bewerbungsunterlagen</h2>
|
||||
<form action="/bewerbung/<%= application.id %>/regenerate" method="POST"
|
||||
onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');">
|
||||
<button type="submit"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
Neu generieren
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Generation status banner -->
|
||||
<div id="genBanner">
|
||||
<% if (genStatus === 'ausstehend') { %>
|
||||
<% if (!genStatus || genStatus === 'nicht_gestartet') { %>
|
||||
<div class="flex items-center gap-3 rounded-lg bg-gray-50 dark:bg-gray-700/40 border border-gray-200 dark:border-gray-700 px-4 py-3 mb-4">
|
||||
<svg class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Noch keine Unterlagen generiert. Ergänze bei Bedarf unten Notizen (z. B. die vollständige Firmenanschrift) und starte die Generierung.</span>
|
||||
</div>
|
||||
<% } else if (genStatus === 'ausstehend') { %>
|
||||
<div class="flex items-center gap-3 rounded-lg bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 px-4 py-3 mb-4">
|
||||
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
@@ -183,6 +180,31 @@
|
||||
<% } %>
|
||||
</div>
|
||||
|
||||
<!-- LLM notes + generate/regenerate -->
|
||||
<form action="/bewerbung/<%= application.id %>/generieren" method="POST"
|
||||
class="mt-6 border-t border-gray-200 dark:border-gray-700 pt-5"
|
||||
<% if (genStatus === 'fertig') { %>onsubmit="return confirm('Unterlagen neu generieren? Vorhandene generierte Dateien werden ersetzt.');"<% } %>>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Notizen für die KI
|
||||
</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
Zusätzliche Infos für die Generierung – z. B. die vollständige Firmenanschrift (Straße, PLZ, Ort),
|
||||
ein Ansprechpartner oder besondere Hinweise zur Bewerbung. Die KI berücksichtigt diese Angaben.
|
||||
</p>
|
||||
<textarea name="llm_notizen" rows="4"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white leading-relaxed"
|
||||
placeholder="z. B. Musterfirma GmbH Musterstraße 12 45128 Essen Ansprechpartner: Frau Müller"><%= application.llm_notizen || '' %></textarea>
|
||||
<div class="flex justify-end mt-3">
|
||||
<button type="submit" <%= genStatus === 'ausstehend' ? 'disabled' : '' %>
|
||||
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-md transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
<%= genStatus === 'ausstehend' ? 'Wird generiert…' : (genStatus === 'fertig' ? 'Notizen speichern & neu generieren' : 'Unterlagen generieren') %>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Job description from the import -->
|
||||
<% if (application.stellenbeschreibung && application.stellenbeschreibung.trim()) { %>
|
||||
<details class="mt-6 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
|
||||
@@ -122,6 +122,67 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Static extra attachments (Zeugnisse etc.) -->
|
||||
<h2 class="text-xl font-bold text-gray-800 dark:text-white mt-10 mb-2">Zusätzliche Anlagen</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
Feste Dateien (z. B. Arbeitszeugnisse, Zertifikate), die jeder generierten Bewerbung
|
||||
automatisch als Anhang beigelegt und im Anschreiben unter „Anlagen" aufgeführt werden.
|
||||
Für einzelne Bewerbungen kannst du den Anhang später auf der Bewerbungsseite wieder entfernen.
|
||||
</p>
|
||||
|
||||
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<% if (basisAnhaenge.length === 0) { %>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Noch keine zusätzlichen Anlagen hinterlegt.</p>
|
||||
<% } else { %>
|
||||
<ul class="space-y-2 mb-2">
|
||||
<% basisAnhaenge.forEach(a => { %>
|
||||
<li class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-3">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<svg class="w-6 h-6 text-red-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= a.name || a.dateiname %></p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= a.dateiname %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0">
|
||||
<a href="/anlagen/<%= a.id %>/download" class="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 hover:underline">Download</a>
|
||||
<form action="/anlagen/<%= a.id %>/delete" method="POST" onsubmit="return confirm('Diese Anlage löschen?');">
|
||||
<button type="submit" class="text-red-500 hover:text-red-700 dark:text-red-400" aria-label="Anlage löschen">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
<% } %>
|
||||
|
||||
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datei (PDF)</label>
|
||||
<input type="file" name="datei" accept="application/pdf,image/*" required
|
||||
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Bezeichnung (optional)</label>
|
||||
<input type="text" name="name"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-white"
|
||||
placeholder="z. B. Arbeitszeugnis">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
|
||||
Anlage hochladen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<%- include('partials/footer') %>
|
||||
|
||||
Reference in New Issue
Block a user