Labels für Stellen (Bewerbungen + Jobangebote), inkl. API & Swagger
Mehrere Labels pro Stelle (Regional, Remote-Deutschlandweit, Homeoffice-Deutschlandweit), gespeichert als JSON-Array in einer neuen labels-Spalte beider Tabellen (Migration). Geteiltes lib/labels.js mit parse/serialize; wiederverwendbare Partials fuer Chips + Mehrfachauswahl. - Web: setzen im Hinzufuegen-Modal, auf der Bearbeiten-Seite und im Jobangebot-Bearbeiten-Formular; Anzeige als Chips in den Listen. - Uebernahme eines Angebots traegt dessen Labels in die neue Bewerbung. - REST-API: labels[] in /applications und /joboffers (GET/POST/PUT), Filter ?label=…; OpenAPI/Swagger-Schemas + Enums erweitert. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+32
-13
@@ -9,6 +9,13 @@ const express = require('express');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const blacklist = require('./blacklist');
|
const blacklist = require('./blacklist');
|
||||||
|
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
|
||||||
|
|
||||||
|
// Replace the stored labels JSON string with a real array on outgoing rows.
|
||||||
|
function withLabels(row) {
|
||||||
|
if (row) row.labels = parseLabels(row.labels);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
const STATUS_OPTIONS = [
|
||||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||||
@@ -60,12 +67,17 @@ function createExternalApi(deps) {
|
|||||||
// --- Applications --------------------------------------------------
|
// --- Applications --------------------------------------------------
|
||||||
router.get('/applications', async (req, res) => {
|
router.get('/applications', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { month, year, status, art, search } = req.query;
|
const { month, year, status, art, search, label } = req.query;
|
||||||
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
|
||||||
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
||||||
|
|
||||||
const where = [];
|
const where = [];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
if (label && LABEL_OPTIONS.includes(label)) {
|
||||||
|
// labels is a JSON array string; match the quoted label token.
|
||||||
|
where.push('labels LIKE ?');
|
||||||
|
params.push(`%"${label}"%`);
|
||||||
|
}
|
||||||
if (month) {
|
if (month) {
|
||||||
where.push('strftime("%m", datum) = ?');
|
where.push('strftime("%m", datum) = ?');
|
||||||
params.push(String(month).padStart(2, '0'));
|
params.push(String(month).padStart(2, '0'));
|
||||||
@@ -94,6 +106,7 @@ function createExternalApi(deps) {
|
|||||||
[...params, limit, offset]
|
[...params, limit, offset]
|
||||||
);
|
);
|
||||||
await attachVerlauf(applications);
|
await attachVerlauf(applications);
|
||||||
|
applications.forEach(withLabels);
|
||||||
res.json(applications);
|
res.json(applications);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API list applications error:', error);
|
console.error('API list applications error:', error);
|
||||||
@@ -125,8 +138,8 @@ function createExternalApi(deps) {
|
|||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO bewerbungen
|
`INSERT INTO bewerbungen
|
||||||
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
(datum, firma, stelle, art, status, notizen, interne_notizen, ort,
|
||||||
stellenbeschreibung, quelle_url, llm_notizen, generierung_status)
|
stellenbeschreibung, quelle_url, llm_notizen, labels, generierung_status)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||||
[
|
[
|
||||||
datum,
|
datum,
|
||||||
sanitizeInput(firma),
|
sanitizeInput(firma),
|
||||||
@@ -139,6 +152,7 @@ function createExternalApi(deps) {
|
|||||||
sanitizeInput(b.stellenbeschreibung || ''),
|
sanitizeInput(b.stellenbeschreibung || ''),
|
||||||
sanitizeInput(b.quelle_url || ''),
|
sanitizeInput(b.quelle_url || ''),
|
||||||
sanitizeInput(b.llm_notizen || ''),
|
sanitizeInput(b.llm_notizen || ''),
|
||||||
|
serializeLabels(b.labels),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -150,7 +164,7 @@ function createExternalApi(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]);
|
||||||
res.json({ success: true, application });
|
res.json({ success: true, application: withLabels(application) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API create application error:', error);
|
console.error('API create application error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -162,7 +176,7 @@ function createExternalApi(deps) {
|
|||||||
const application = await getApplication(req.params.id);
|
const application = await getApplication(req.params.id);
|
||||||
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
if (!application) return res.status(404).json({ error: 'Bewerbung nicht gefunden' });
|
||||||
await attachVerlauf([application]);
|
await attachVerlauf([application]);
|
||||||
res.json(application);
|
res.json(withLabels(application));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API get application error:', error);
|
console.error('API get application error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -184,7 +198,7 @@ function createExternalApi(deps) {
|
|||||||
`UPDATE bewerbungen SET
|
`UPDATE bewerbungen SET
|
||||||
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
|
datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?,
|
||||||
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
|
interne_notizen = ?, ort = ?, stellenbeschreibung = ?, quelle_url = ?,
|
||||||
llm_notizen = ?, updated_at = CURRENT_TIMESTAMP
|
llm_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[
|
[
|
||||||
b.datum,
|
b.datum,
|
||||||
@@ -198,12 +212,15 @@ function createExternalApi(deps) {
|
|||||||
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
|
sanitizeInput(b.stellenbeschreibung ?? existing.stellenbeschreibung),
|
||||||
sanitizeInput(b.quelle_url ?? existing.quelle_url),
|
sanitizeInput(b.quelle_url ?? existing.quelle_url),
|
||||||
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
|
sanitizeInput(b.llm_notizen ?? existing.llm_notizen),
|
||||||
|
Object.prototype.hasOwnProperty.call(b, 'labels')
|
||||||
|
? serializeLabels(b.labels)
|
||||||
|
: (existing.labels || '[]'),
|
||||||
id,
|
id,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||||
res.json({ success: true, application });
|
res.json({ success: true, application: withLabels(application) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API update application error:', error);
|
console.error('API update application error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -516,6 +533,7 @@ function createExternalApi(deps) {
|
|||||||
const rows = await dbAll(
|
const rows = await dbAll(
|
||||||
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
|
`SELECT * FROM jobangebote ORDER BY created_at DESC, id DESC`
|
||||||
);
|
);
|
||||||
|
rows.forEach(withLabels);
|
||||||
res.json(rows);
|
res.json(rows);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API list job offers error:', error);
|
console.error('API list job offers error:', error);
|
||||||
@@ -583,7 +601,7 @@ function createExternalApi(deps) {
|
|||||||
try {
|
try {
|
||||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||||
res.json(row);
|
res.json(withLabels(row));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API get job offer error:', error);
|
console.error('API get job offer error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
@@ -628,6 +646,7 @@ function createExternalApi(deps) {
|
|||||||
anzeigeDatum,
|
anzeigeDatum,
|
||||||
kontaktEmail,
|
kontaktEmail,
|
||||||
sanitizeInput(b.status || 'offen'),
|
sanitizeInput(b.status || 'offen'),
|
||||||
|
serializeLabels(b.labels),
|
||||||
urlNorm,
|
urlNorm,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -651,21 +670,21 @@ function createExternalApi(deps) {
|
|||||||
await dbRun(
|
await dbRun(
|
||||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
||||||
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
||||||
url_norm = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
labels = ?, url_norm = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||||
[...fields, existing.id]
|
[...fields, existing.id]
|
||||||
);
|
);
|
||||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
|
||||||
return res.json({ success: true, action: 'updated', joboffer: row });
|
return res.json({ success: true, action: 'updated', joboffer: withLabels(row) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO jobangebote
|
`INSERT INTO jobangebote
|
||||||
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, url_norm)
|
(external_id, quelle, firma, stelle, ort, adresse, ansprechpartner, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, labels, url_norm)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[externalId, quelle, ...fields]
|
[externalId, quelle, ...fields]
|
||||||
);
|
);
|
||||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
|
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
|
||||||
res.status(201).json({ success: true, action: 'created', joboffer: row });
|
res.status(201).json({ success: true, action: 'created', joboffer: withLabels(row) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API create job offer error:', error);
|
console.error('API create job offer error:', error);
|
||||||
res.status(500).json({ error: 'Serverfehler' });
|
res.status(500).json({ error: 'Serverfehler' });
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Shared label vocabulary for Stellen (Bewerbungen + Jobangebote).
|
||||||
|
//
|
||||||
|
// Labels describe the work-location character of a position. Several labels may
|
||||||
|
// be set per Stelle. They are stored as a JSON array string in the `labels`
|
||||||
|
// column and exposed as a string[] through the web UI and the REST API.
|
||||||
|
|
||||||
|
const LABEL_OPTIONS = [
|
||||||
|
'Regional',
|
||||||
|
'Remote-Deutschlandweit',
|
||||||
|
'Homeoffice-Deutschlandweit',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Normalize any incoming labels value (array, JSON string, comma-separated
|
||||||
|
// string, or a single label) into a clean, de-duplicated array limited to
|
||||||
|
// LABEL_OPTIONS and returned in canonical order for stable output.
|
||||||
|
function parseLabels(raw) {
|
||||||
|
if (raw == null || raw === '') return [];
|
||||||
|
let arr;
|
||||||
|
if (Array.isArray(raw)) {
|
||||||
|
arr = raw;
|
||||||
|
} else if (typeof raw === 'string') {
|
||||||
|
const s = raw.trim();
|
||||||
|
if (!s) return [];
|
||||||
|
if (s[0] === '[') {
|
||||||
|
try { arr = JSON.parse(s); } catch (e) { arr = []; }
|
||||||
|
if (!Array.isArray(arr)) arr = [];
|
||||||
|
} else {
|
||||||
|
arr = s.split(',');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const chosen = new Set(
|
||||||
|
arr.map((v) => String(v).trim()).filter((v) => LABEL_OPTIONS.includes(v))
|
||||||
|
);
|
||||||
|
return LABEL_OPTIONS.filter((l) => chosen.has(l));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical JSON string for storage in the DB (always a valid array).
|
||||||
|
function serializeLabels(raw) {
|
||||||
|
return JSON.stringify(parseLabels(raw));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { LABEL_OPTIONS, parseLabels, serializeLabels };
|
||||||
@@ -11,6 +11,14 @@ const STATUS_OPTIONS = [
|
|||||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||||
'Absage', 'Einstellung', 'Keine Rückmeldung',
|
'Absage', 'Einstellung', 'Keine Rückmeldung',
|
||||||
];
|
];
|
||||||
|
const { LABEL_OPTIONS } = require('./labels');
|
||||||
|
|
||||||
|
// Reusable schema for the labels array (work-location labels of a Stelle).
|
||||||
|
const labelsSchema = {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'string', enum: LABEL_OPTIONS },
|
||||||
|
description: 'Labels der Stelle (Arbeitsort). Mehrere möglich.',
|
||||||
|
};
|
||||||
|
|
||||||
// Shared reusable schemas -----------------------------------------------
|
// Shared reusable schemas -----------------------------------------------
|
||||||
|
|
||||||
@@ -79,6 +87,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' }, description: 'Jahr (z. B. 2026)' },
|
{ name: 'year', in: 'query', schema: { type: 'string', pattern: '^[0-9]{4}$' }, description: 'Jahr (z. B. 2026)' },
|
||||||
{ name: 'status', in: 'query', schema: { type: 'string', enum: STATUS_OPTIONS }, description: 'Filter nach Status' },
|
{ name: 'status', in: 'query', schema: { type: 'string', enum: STATUS_OPTIONS }, description: 'Filter nach Status' },
|
||||||
{ name: 'art', in: 'query', schema: { type: 'string', enum: ART_OPTIONS }, description: 'Filter nach Bewerbungsart' },
|
{ name: 'art', in: 'query', schema: { type: 'string', enum: ART_OPTIONS }, description: 'Filter nach Bewerbungsart' },
|
||||||
|
{ name: 'label', in: 'query', schema: { type: 'string', enum: LABEL_OPTIONS }, description: 'Filter nach Label (Stellen mit diesem Label)' },
|
||||||
{ name: 'search', in: 'query', schema: { type: 'string' }, description: 'Freitextsuche in Firma und Stelle' },
|
{ name: 'search', in: 'query', schema: { type: 'string' }, description: 'Freitextsuche in Firma und Stelle' },
|
||||||
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500, default: 100 }, description: 'Max. Anzahl Ergebnisse' },
|
{ name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1, maximum: 500, default: 100 }, description: 'Max. Anzahl Ergebnisse' },
|
||||||
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 }, description: 'Ergebnis-Offset (Paging)' },
|
{ name: 'offset', in: 'query', schema: { type: 'integer', minimum: 0, default: 0 }, description: 'Ergebnis-Offset (Paging)' },
|
||||||
@@ -663,6 +672,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
stelle: { type: 'string' },
|
stelle: { type: 'string' },
|
||||||
art: { type: 'string', enum: ART_OPTIONS, nullable: true },
|
art: { type: 'string', enum: ART_OPTIONS, nullable: true },
|
||||||
status: { type: 'string', enum: STATUS_OPTIONS, nullable: true },
|
status: { type: 'string', enum: STATUS_OPTIONS, nullable: true },
|
||||||
|
labels: { ...labelsSchema, nullable: true },
|
||||||
notizen: { type: 'string', nullable: true },
|
notizen: { type: 'string', nullable: true },
|
||||||
interne_notizen: { type: 'string', nullable: true },
|
interne_notizen: { type: 'string', nullable: true },
|
||||||
ort: { type: 'string', nullable: true },
|
ort: { type: 'string', nullable: true },
|
||||||
@@ -692,6 +702,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
stelle: { type: 'string' },
|
stelle: { type: 'string' },
|
||||||
art: { type: 'string', enum: ART_OPTIONS },
|
art: { type: 'string', enum: ART_OPTIONS },
|
||||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||||
|
labels: labelsSchema,
|
||||||
notizen: { type: 'string' },
|
notizen: { type: 'string' },
|
||||||
interne_notizen: { type: 'string' },
|
interne_notizen: { type: 'string' },
|
||||||
ort: { type: 'string' },
|
ort: { type: 'string' },
|
||||||
@@ -711,6 +722,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
stelle: { type: 'string' },
|
stelle: { type: 'string' },
|
||||||
art: { type: 'string', enum: ART_OPTIONS },
|
art: { type: 'string', enum: ART_OPTIONS },
|
||||||
status: { type: 'string', enum: STATUS_OPTIONS },
|
status: { type: 'string', enum: STATUS_OPTIONS },
|
||||||
|
labels: labelsSchema,
|
||||||
notizen: { type: 'string' },
|
notizen: { type: 'string' },
|
||||||
interne_notizen: { type: 'string' },
|
interne_notizen: { type: 'string' },
|
||||||
ort: { type: 'string' },
|
ort: { type: 'string' },
|
||||||
@@ -871,6 +883,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
|
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
|
||||||
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
||||||
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
|
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
|
||||||
|
labels: { ...labelsSchema, nullable: true },
|
||||||
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
|
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
|
||||||
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
|
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
|
||||||
created_at: { type: 'string', format: 'date-time' },
|
created_at: { type: 'string', format: 'date-time' },
|
||||||
@@ -895,6 +908,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
|||||||
anzeige_datum: { type: 'string', format: 'date', description: 'Datum der eigentlichen Stellenanzeige (ISO YYYY-MM-DD)' },
|
anzeige_datum: { type: 'string', format: 'date', description: 'Datum der eigentlichen Stellenanzeige (ISO YYYY-MM-DD)' },
|
||||||
kontakt_email: { type: 'string', format: 'email', description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
kontakt_email: { type: 'string', format: 'email', description: 'Kontakt-E-Mail-Adresse der Stelle' },
|
||||||
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'], default: 'offen' },
|
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'], default: 'offen' },
|
||||||
|
labels: labelsSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
JobOfferResult: {
|
JobOfferResult: {
|
||||||
|
|||||||
+2
-1
@@ -184,7 +184,8 @@ function saveApplication(event) {
|
|||||||
status: formData.get('status'),
|
status: formData.get('status'),
|
||||||
notizen: formData.get('notizen'),
|
notizen: formData.get('notizen'),
|
||||||
interne_notizen: formData.get('interne_notizen'),
|
interne_notizen: formData.get('interne_notizen'),
|
||||||
kommentar: formData.get('kommentar')
|
kommentar: formData.get('kommentar'),
|
||||||
|
labels: formData.getAll('labels')
|
||||||
};
|
};
|
||||||
|
|
||||||
let url = '/api/bewerbungen';
|
let url = '/api/bewerbungen';
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const { createExternalApi } = require('./lib/api');
|
|||||||
const { buildOpenApiSpec } = require('./lib/openapi');
|
const { buildOpenApiSpec } = require('./lib/openapi');
|
||||||
const blacklist = require('./lib/blacklist');
|
const blacklist = require('./lib/blacklist');
|
||||||
const caldav = require('./lib/caldav');
|
const caldav = require('./lib/caldav');
|
||||||
|
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./lib/labels');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
@@ -766,6 +767,9 @@ function initializeDatabase() {
|
|||||||
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
FOREIGN KEY (verknuepfte_bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
// Migration: labels (JSON array of work-location labels) on both Stellen tables.
|
||||||
|
db.run('ALTER TABLE bewerbungen ADD COLUMN labels TEXT', () => {});
|
||||||
|
db.run('ALTER TABLE jobangebote ADD COLUMN labels TEXT', () => {});
|
||||||
// Migration: add columns to pre-existing jobangebote tables.
|
// Migration: add columns to pre-existing jobangebote tables.
|
||||||
db.run('ALTER TABLE jobangebote ADD COLUMN anzeige_datum DATE', () => {});
|
db.run('ALTER TABLE jobangebote ADD COLUMN anzeige_datum DATE', () => {});
|
||||||
db.run('ALTER TABLE jobangebote ADD COLUMN kontakt_email TEXT', () => {});
|
db.run('ALTER TABLE jobangebote ADD COLUMN kontakt_email TEXT', () => {});
|
||||||
@@ -898,6 +902,7 @@ initializeDatabase().then(() => {
|
|||||||
|
|
||||||
const applications = await dbAll(query, params);
|
const applications = await dbAll(query, params);
|
||||||
await attachVerlauf(applications);
|
await attachVerlauf(applications);
|
||||||
|
applications.forEach((a) => { a.labelsArr = parseLabels(a.labels); });
|
||||||
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
|
||||||
// Upcoming calendar appointments for the dashboard widget.
|
// Upcoming calendar appointments for the dashboard widget.
|
||||||
const kommendeTermine = await upcomingTermine(6);
|
const kommendeTermine = await upcomingTermine(6);
|
||||||
@@ -954,7 +959,8 @@ initializeDatabase().then(() => {
|
|||||||
kommendeTermine,
|
kommendeTermine,
|
||||||
caldavTz: caldav.TZ,
|
caldavTz: caldav.TZ,
|
||||||
artOptions: ART_OPTIONS,
|
artOptions: ART_OPTIONS,
|
||||||
statusOptions: STATUS_OPTIONS
|
statusOptions: STATUS_OPTIONS,
|
||||||
|
labelOptions: LABEL_OPTIONS
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
@@ -1108,6 +1114,7 @@ initializeDatabase().then(() => {
|
|||||||
app.post('/api/bewerbungen', async (req, res) => {
|
app.post('/api/bewerbungen', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
|
const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body;
|
||||||
|
const labels = serializeLabels(req.body.labels);
|
||||||
|
|
||||||
// Duplicate guard: warn before creating a second application for the same
|
// Duplicate guard: warn before creating a second application for the same
|
||||||
// company + role (the client re-submits with force=true to confirm).
|
// company + role (the client re-submits with force=true to confirm).
|
||||||
@@ -1124,9 +1131,9 @@ initializeDatabase().then(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen, labels) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
||||||
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)]
|
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Record the initial status as the first timeline entry
|
// Record the initial status as the first timeline entry
|
||||||
@@ -1151,11 +1158,15 @@ initializeDatabase().then(() => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { datum, firma, stelle, art, status, notizen } = req.body;
|
const { datum, firma, stelle, art, status, notizen } = req.body;
|
||||||
|
const existing = await dbGet('SELECT labels FROM bewerbungen WHERE id = ?', [id]);
|
||||||
|
const labels = Object.prototype.hasOwnProperty.call(req.body, 'labels')
|
||||||
|
? serializeLabels(req.body.labels)
|
||||||
|
: (existing ? existing.labels : '[]');
|
||||||
|
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
[datum, sanitizeInput(firma), sanitizeInput(stelle),
|
||||||
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id]
|
sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), labels, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||||
@@ -1234,6 +1245,7 @@ initializeDatabase().then(() => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]);
|
||||||
if (!application) return res.status(404).send('Bewerbung nicht gefunden');
|
if (!application) return res.status(404).send('Bewerbung nicht gefunden');
|
||||||
|
application.labelsArr = parseLabels(application.labels);
|
||||||
|
|
||||||
const verlauf = await dbAll(
|
const verlauf = await dbAll(
|
||||||
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
|
'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC',
|
||||||
@@ -1294,6 +1306,7 @@ initializeDatabase().then(() => {
|
|||||||
terminError: req.query.terminerror ? String(req.query.terminerror) : '',
|
terminError: req.query.terminerror ? String(req.query.terminerror) : '',
|
||||||
artOptions: ART_OPTIONS,
|
artOptions: ART_OPTIONS,
|
||||||
statusOptions: STATUS_OPTIONS,
|
statusOptions: STATUS_OPTIONS,
|
||||||
|
labelOptions: LABEL_OPTIONS,
|
||||||
hideSettings: true
|
hideSettings: true
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1307,10 +1320,11 @@ initializeDatabase().then(() => {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
|
const { datum, firma, stelle, art, notizen, interne_notizen } = req.body;
|
||||||
|
const labels = serializeLabels(req.body.labels);
|
||||||
|
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, labels = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
[datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), id]
|
[datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), labels, id]
|
||||||
);
|
);
|
||||||
|
|
||||||
res.redirect('/bewerbung/' + id);
|
res.redirect('/bewerbung/' + id);
|
||||||
@@ -1995,12 +2009,14 @@ initializeDatabase().then(() => {
|
|||||||
WHERE j.status = 'offen'
|
WHERE j.status = 'offen'
|
||||||
ORDER BY j.created_at DESC, j.id DESC`
|
ORDER BY j.created_at DESC, j.id DESC`
|
||||||
);
|
);
|
||||||
|
jobangebote.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
||||||
const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'uebernommen'");
|
const uebernommenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'uebernommen'");
|
||||||
res.render('jobangebote', {
|
res.render('jobangebote', {
|
||||||
jobangebote,
|
jobangebote,
|
||||||
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0,
|
uebernommenCount: uebernommenRow ? uebernommenRow.c : 0,
|
||||||
artOptions: ART_OPTIONS,
|
artOptions: ART_OPTIONS,
|
||||||
statusOptions: STATUS_OPTIONS,
|
statusOptions: STATUS_OPTIONS,
|
||||||
|
labelOptions: LABEL_OPTIONS,
|
||||||
hideSettings: false,
|
hideSettings: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2019,6 +2035,7 @@ initializeDatabase().then(() => {
|
|||||||
WHERE j.status = 'uebernommen'
|
WHERE j.status = 'uebernommen'
|
||||||
ORDER BY j.updated_at DESC, j.id DESC`
|
ORDER BY j.updated_at DESC, j.id DESC`
|
||||||
);
|
);
|
||||||
|
uebernommen.forEach((j) => { j.labelsArr = parseLabels(j.labels); });
|
||||||
const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'offen'");
|
const offenRow = await dbGet("SELECT COUNT(*) AS c FROM jobangebote WHERE status = 'offen'");
|
||||||
res.render('jobangebote_uebernommen', {
|
res.render('jobangebote_uebernommen', {
|
||||||
uebernommen,
|
uebernommen,
|
||||||
@@ -2064,8 +2081,8 @@ initializeDatabase().then(() => {
|
|||||||
const result = await dbRun(
|
const result = await dbRun(
|
||||||
`INSERT INTO bewerbungen
|
`INSERT INTO bewerbungen
|
||||||
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
|
(datum, firma, stelle, art, status, notizen, ort, stellenbeschreibung,
|
||||||
quelle_url, email_empfaenger, llm_notizen, generierung_status)
|
quelle_url, email_empfaenger, llm_notizen, labels, generierung_status)
|
||||||
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
VALUES (?, ?, ?, ?, 'Entwurf', ?, ?, ?, ?, ?, ?, ?, 'nicht_gestartet')`,
|
||||||
[
|
[
|
||||||
datum,
|
datum,
|
||||||
sanitizeInput(angebot.firma),
|
sanitizeInput(angebot.firma),
|
||||||
@@ -2077,6 +2094,7 @@ initializeDatabase().then(() => {
|
|||||||
angebot.quelle_url || '',
|
angebot.quelle_url || '',
|
||||||
angebot.kontakt_email || '',
|
angebot.kontakt_email || '',
|
||||||
llmNotizen,
|
llmNotizen,
|
||||||
|
serializeLabels(angebot.labels),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2115,7 +2133,7 @@ initializeDatabase().then(() => {
|
|||||||
await dbRun(
|
await dbRun(
|
||||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, adresse = ?, ansprechpartner = ?, gehalt = ?,
|
||||||
beschreibung = ?, kontakt_email = ?, quelle_url = ?, anzeige_datum = ?,
|
beschreibung = ?, kontakt_email = ?, quelle_url = ?, anzeige_datum = ?,
|
||||||
url_norm = ?, updated_at = CURRENT_TIMESTAMP
|
labels = ?, url_norm = ?, updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
[
|
[
|
||||||
sanitizeInput((b.firma || '').trim()) || angebot.firma,
|
sanitizeInput((b.firma || '').trim()) || angebot.firma,
|
||||||
@@ -2128,6 +2146,7 @@ initializeDatabase().then(() => {
|
|||||||
sanitizeInput(b.kontakt_email || ''),
|
sanitizeInput(b.kontakt_email || ''),
|
||||||
quelleUrl,
|
quelleUrl,
|
||||||
sanitizeInput(b.anzeige_datum || ''),
|
sanitizeInput(b.anzeige_datum || ''),
|
||||||
|
serializeLabels(b.labels),
|
||||||
blacklist.normalizeUrl(b.quelle_url || '') || null,
|
blacklist.normalizeUrl(b.quelle_url || '') || null,
|
||||||
req.params.id,
|
req.params.id,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -69,6 +69,10 @@
|
|||||||
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">
|
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">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Labels</label>
|
||||||
|
<%- include('partials/labelPicker', { labelOptions: labelOptions, selected: application.labelsArr }) %>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notizen</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notizen</label>
|
||||||
<textarea name="notizen" rows="6"
|
<textarea name="notizen" rows="6"
|
||||||
|
|||||||
+15
-1
@@ -276,7 +276,12 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="whitespace-nowrap text-gray-900 dark:text-white"><%= new Date(app.datum).toLocaleDateString('de-DE') %></span>
|
<span class="whitespace-nowrap text-gray-900 dark:text-white"><%= new Date(app.datum).toLocaleDateString('de-DE') %></span>
|
||||||
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" title="<%= app.firma %>"><%= app.firma %></span>
|
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" title="<%= app.firma %>"><%= app.firma %></span>
|
||||||
<span class="min-w-0 truncate text-gray-900 dark:text-white" title="<%= app.stelle %>"><%= app.stelle %></span>
|
<span class="min-w-0">
|
||||||
|
<span class="block truncate text-gray-900 dark:text-white" title="<%= app.stelle %>"><%= app.stelle %></span>
|
||||||
|
<% if (app.labelsArr && app.labelsArr.length) { %>
|
||||||
|
<span class="flex flex-wrap gap-1 mt-1"><%- include('partials/labelChips', { labels: app.labelsArr }) %></span>
|
||||||
|
<% } %>
|
||||||
|
</span>
|
||||||
<span class="min-w-0 truncate text-gray-500 dark:text-gray-400"><%= app.art || '-' %></span>
|
<span class="min-w-0 truncate text-gray-500 dark:text-gray-400"><%= app.art || '-' %></span>
|
||||||
<span class="min-w-0">
|
<span class="min-w-0">
|
||||||
<span class="inline-block max-w-full truncate align-middle px-2 py-1 rounded-full text-xs font-medium
|
<span class="inline-block max-w-full truncate align-middle px-2 py-1 rounded-full text-xs font-medium
|
||||||
@@ -601,6 +606,15 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Labels
|
||||||
|
</label>
|
||||||
|
<div id="applicationLabels">
|
||||||
|
<%- include('partials/labelPicker', { labelOptions: labelOptions, selected: [] }) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="applicationStatus" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<label for="applicationStatus" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Status (Anfangsstatus)
|
Status (Anfangsstatus)
|
||||||
|
|||||||
@@ -68,6 +68,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
|
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
|
||||||
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
|
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
|
||||||
|
<% if (j.labelsArr && j.labelsArr.length) { %>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1.5"><%- include('partials/labelChips', { labels: j.labelsArr }) %></div>
|
||||||
|
<% } %>
|
||||||
<% if (j.ort || j.gehalt) { %>
|
<% if (j.ort || j.gehalt) { %>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
<% if (j.ort) { %><%= j.ort %><% } %>
|
<% if (j.ort) { %><%= j.ort %><% } %>
|
||||||
@@ -212,6 +215,10 @@
|
|||||||
<span class="text-gray-500 dark:text-gray-400">Stellenbeschreibung <span class="text-gray-400">(beliebig lang — wird bei „Übernehmen“ als Stellenbeschreibung für die KI mitgenommen)</span></span>
|
<span class="text-gray-500 dark:text-gray-400">Stellenbeschreibung <span class="text-gray-400">(beliebig lang — wird bei „Übernehmen“ als Stellenbeschreibung für die KI mitgenommen)</span></span>
|
||||||
<textarea name="beschreibung" rows="10" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm leading-relaxed" placeholder="Vollständige Stellenbeschreibung hier einfügen …"><%= j.beschreibung || '' %></textarea>
|
<textarea name="beschreibung" rows="10" class="mt-1 w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm leading-relaxed" placeholder="Vollständige Stellenbeschreibung hier einfügen …"><%= j.beschreibung || '' %></textarea>
|
||||||
</label>
|
</label>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Labels</span>
|
||||||
|
<div class="mt-1"><%- include('partials/labelPicker', { labelOptions: labelOptions, selected: j.labelsArr }) %></div>
|
||||||
|
</div>
|
||||||
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
|
<button type="submit" class="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs 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="M5 13l4 4L19 7"></path></svg>
|
<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="M5 13l4 4L19 7"></path></svg>
|
||||||
Speichern
|
Speichern
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<%
|
||||||
|
// Display-only label chips. Expects local `labels` (array of label strings).
|
||||||
|
var _labelClasses = {
|
||||||
|
'Regional': 'bg-sky-100 text-sky-800 dark:bg-sky-900/50 dark:text-sky-200',
|
||||||
|
'Remote-Deutschlandweit': 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/50 dark:text-emerald-200',
|
||||||
|
'Homeoffice-Deutschlandweit': 'bg-violet-100 text-violet-800 dark:bg-violet-900/50 dark:text-violet-200'
|
||||||
|
};
|
||||||
|
var _labels = (typeof labels !== 'undefined' && labels) ? labels : [];
|
||||||
|
%>
|
||||||
|
<% _labels.forEach(function (lbl) { %>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[11px] font-medium leading-tight <%= _labelClasses[lbl] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' %>"><%= lbl %></span>
|
||||||
|
<% }); %>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%
|
||||||
|
// Multi-select label picker (checkboxes named "labels"). Expects locals:
|
||||||
|
// labelOptions – array of all selectable labels
|
||||||
|
// selected – array of currently selected labels (optional)
|
||||||
|
var _opts = (typeof labelOptions !== 'undefined' && labelOptions) ? labelOptions : [];
|
||||||
|
var _sel = (typeof selected !== 'undefined' && selected) ? selected : [];
|
||||||
|
%>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% _opts.forEach(function (opt) { %>
|
||||||
|
<label class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border cursor-pointer text-sm transition-colors border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
<input type="checkbox" name="labels" value="<%= opt %>" <%= _sel.indexOf(opt) !== -1 ? 'checked' : '' %>
|
||||||
|
class="rounded border-gray-300 dark:border-gray-500 text-blue-600 focus:ring-blue-500">
|
||||||
|
<span class="text-gray-700 dark:text-gray-200"><%= opt %></span>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user