/** * Initial-call questionnaire ("Erstgespräch-Fragebogen") for leads. * * ONE catalog shared by the modal (app/components/offers/LeadQuestionnaireModal.vue) * and the server PDF renderer (server/utils/offers/questionnairePdf.ts) — the * server imports it via `../../../app/utils/leadQuestionnaire` exactly like * `app/utils/iban.ts`. Keep it dependency-free (no Vue / no h3). * * Question types: * bool → Ja / Nein checkboxes (answer: true | false | null) * number → numeric input (+ optional unit) (answer: number | '') * text → single-line text (answer: string) * textarea→ multi-line text (answer: string) * choice → exactly one of `options` (exclusive boxes) (answer: option key | '') * multi → any of `options` (checkbox list) (answer: option keys[]) * Any question may carry `detail` → an extra free-text line ("Welche? / Which?") * whose answer is stored under `_detail`. `unitChoice` renders a small * exclusive unit picker next to a number (answer `_unit`). * * Adding a question = one entry here; the form, the PDF, the saved JSON * (offer_conditions.questionnaire.answers) and the activity summary follow. */ export type QLang = 'de' | 'en' export type QType = 'bool' | 'number' | 'text' | 'textarea' | 'choice' | 'multi' export interface QOption { key: string; de: string; en: string; other?: boolean } export interface QQuestion { id: string type: QType de: string en: string /** Muted helper line under the label. */ hintDe?: string hintEn?: string /** Unit label printed after a number (e.g. "kg"). */ unit?: string /** Exclusive unit picker for numbers (e.g. "%" vs "Stück"). */ unitChoice?: QOption[] options?: QOption[] /** Extra free-text line (label per language) stored as `_detail`. */ detail?: { de: string; en: string } /** Placeholder for text inputs. */ placeholderDe?: string placeholderEn?: string } export interface QCategory { id: string; de: string; en: string; icon: string; questions: QQuestion[] } export const QUESTIONNAIRE_VERSION = 1 export const QUESTIONNAIRE: QCategory[] = [ { id: 'status', de: 'Aktuelle Situation', en: 'Current situation', icon: 'ti-building-store', questions: [ { id: 'selling', type: 'bool', de: 'Wird bereits verkauft / versendet?', en: 'Already selling / shipping?' }, { id: 'fulfilment', type: 'choice', de: 'Versand aktuell selbst oder über Dienstleister?', en: 'Shipping currently in-house or via a service provider?', options: [ { key: 'self', de: 'Selbst', en: 'In-house' }, { key: 'provider', de: 'Dienstleister', en: 'Service provider' } ], detail: { de: 'Welcher Dienstleister?', en: 'Which provider?' } }, { id: 'platforms', type: 'multi', de: 'Anzubindende Plattform(en) / Shopsystem', en: 'Platform(s) / shop system to integrate', options: [ { key: 'shopify', de: 'Shopify', en: 'Shopify' }, { key: 'amazon', de: 'Amazon', en: 'Amazon' }, { key: 'ebay', de: 'eBay', en: 'eBay' }, { key: 'woocommerce', de: 'WooCommerce', en: 'WooCommerce' }, { key: 'shopware', de: 'Shopware', en: 'Shopware' }, { key: 'jtl', de: 'JTL', en: 'JTL' }, { key: 'plenty', de: 'PlentyONE', en: 'PlentyONE' }, { key: 'otto', de: 'Otto', en: 'Otto' }, { key: 'zalando', de: 'Zalando', en: 'Zalando' }, { key: 'kaufland', de: 'Kaufland', en: 'Kaufland' }, { key: 'other', de: 'Sonstige', en: 'Other', other: true } ], detail: { de: 'Sonstige / Details', en: 'Other / details' } }, { id: 'eta', type: 'text', de: 'Geplanter Start der Zusammenarbeit (ETA)', en: 'Planned start of cooperation (ETA)', placeholderDe: 'z. B. Oktober 2026', placeholderEn: 'e.g. October 2026' } ] }, { id: 'volume', de: 'Auftragsvolumen', en: 'Order volume', icon: 'ti-chart-bar', questions: [ { id: 'ordersMonth', type: 'number', de: 'Ø Bestellungen pro Monat (aktuell)', en: 'Avg. orders per month (current)', unit: '/ Monat' }, { id: 'ordersTarget12', type: 'number', de: 'Zielmenge Bestellungen in 12 Monaten', en: 'Target orders per month in 12 months', unit: '/ Monat' }, { id: 'picksOrder', type: 'number', de: 'Ø Picks (Positionen) pro Bestellung', en: 'Avg. picks (line items) per order' }, { id: 'returns', type: 'number', de: 'Ø Retouren pro Monat', en: 'Avg. returns per month', unitChoice: [ { key: 'percent', de: '% der Bestellungen', en: '% of orders' }, { key: 'absolute', de: 'Stück (absolut)', en: 'pieces (absolute)' } ] } ] }, { id: 'articles', de: 'Artikel', en: 'Articles', icon: 'ti-package', questions: [ { id: 'skuCount', type: 'number', de: 'Anzahl verschiedener SKUs (Artikel)', en: 'Number of different SKUs (articles)' }, { id: 'weightAvg', type: 'number', de: 'Ø Gewicht je Artikel', en: 'Avg. weight per article', unit: 'kg' }, { id: 'sizeAvg', type: 'text', de: 'Ø Größe je Artikel (L × B × H)', en: 'Avg. size per article (L × W × H)', placeholderDe: 'z. B. 20 × 15 × 10 cm', placeholderEn: 'e.g. 20 × 15 × 10 cm' }, { id: 'barcode', type: 'bool', de: 'Barcode (EAN/GTIN) auf den Artikeln vorhanden?', en: 'Barcode (EAN/GTIN) present on the articles?' }, { id: 'barcodeDigital', type: 'bool', de: 'Barcode-Nummern digital sauber hinterlegt (Webshop / ERP)?', en: 'Barcode numbers stored cleanly in digital form (webshop / ERP)?', hintDe: 'Je Artikel als EAN/GTIN-Feld gepflegt, nicht nur auf dem Etikett', hintEn: 'Maintained per article as an EAN/GTIN field, not only printed on the label' }, { id: 'mhd', type: 'bool', de: 'MHD (Mindesthaltbarkeitsdatum) relevant?', en: 'Best-before date (BBD) relevant?', hintDe: 'Chargen-/FEFO-Verwaltung nötig', hintEn: 'Requires batch / FEFO handling' }, { id: 'serials', type: 'bool', de: 'Seriennummern müssen erfasst, mitgesendet und nachverfolgt werden?', en: 'Serial numbers must be captured, sent with the order and tracked?' } ] }, { id: 'services', de: 'Zusatzleistungen', en: 'Additional services', icon: 'ti-tools', questions: [ { id: 'customization', type: 'bool', de: 'Individualisierung notwendig (Beilagen, Sticker, Geschenkverpackung, …)?', en: 'Customisation required (inserts, stickers, gift wrapping, …)?', detail: { de: 'Welche?', en: 'Which?' } }, { id: 'assembly', type: 'bool', de: 'Konfektionierung / Produktion notwendig (Sets, Bundles, Umverpacken)?', en: 'Kitting / assembly (production) required (sets, bundles, repacking)?', detail: { de: 'Was genau?', en: 'What exactly?' } }, { id: 'amazonPrime', type: 'bool', de: 'Amazon Prime (Seller Fulfilled Prime) notwendig – Versand bis 14:00 Uhr?', en: 'Amazon Prime (Seller Fulfilled Prime) required – same-day dispatch until 14:00?' }, { id: 'amazonFba', type: 'bool', de: 'Amazon FBA notwendig?', en: 'Amazon FBA required?' }, { id: 'amazonPreFba', type: 'bool', de: 'Amazon Pre-FBA (Vorbereitung inkl. FNSKU-Umetikettierung) notwendig?', en: 'Amazon Pre-FBA (prep incl. FNSKU relabelling) required?' } ] }, { id: 'inbound', de: 'Wareneingang & Lagerung', en: 'Inbound & storage', icon: 'ti-truck-loading', questions: [ { id: 'initialStock', type: 'textarea', de: 'Erstanlieferung: Menge, die eingelagert wird (Paletten / Kartons / Stückzahl)', en: 'Initial delivery: quantity to be stored (pallets / cartons / pieces)', placeholderDe: 'z. B. 6 Paletten, ca. 4.000 Artikel', placeholderEn: 'e.g. 6 pallets, approx. 4,000 articles' }, { id: 'inboundMonth', type: 'number', de: 'Ø Wareneingänge (Lieferanten-Lieferungen) pro Monat', en: 'Avg. inbound shipments (supplier deliveries) per month', unit: '/ Monat' }, { id: 'inboundSorted', type: 'choice', de: 'Wareneingänge sortenrein oder gemischt?', en: 'Inbound shipments single-SKU (sorted) or mixed?', options: [ { key: 'sorted', de: 'Sortenrein', en: 'Sorted (single SKU per carton/pallet)' }, { key: 'mixed', de: 'Gemischt / unsortiert', en: 'Mixed / unsorted' } ] } ] }, { id: 'admin', de: 'Organisatorisches', en: 'Organisational', icon: 'ti-file-invoice', questions: [ { id: 'vatId', type: 'bool', de: 'Umsatzsteuer-ID vorhanden?', en: 'VAT ID available?', detail: { de: 'USt-IdNr.', en: 'VAT ID' } }, { id: 'notes', type: 'textarea', de: 'Weitere Notizen / Besonderheiten', en: 'Further notes / special requirements' } ] } ] /** Flat list of all questions (category id attached). */ export const questionnaireQuestions = (): Array => QUESTIONNAIRE.flatMap((c) => c.questions.map((q) => ({ ...q, categoryId: c.id }))) /** Empty answer object with every key present (form v-model friendly). */ export const emptyQuestionnaireAnswers = (): Record => { const out: Record = {} for (const q of questionnaireQuestions()) { out[q.id] = q.type === 'bool' ? null : q.type === 'multi' ? [] : '' if (q.detail) out[`${q.id}_detail`] = '' if (q.unitChoice) out[`${q.id}_unit`] = q.unitChoice[0]?.key || '' } return out } /** Merge saved answers into a fresh answer object (unknown keys ignored). */ export const mergeQuestionnaireAnswers = (saved: any): Record => { const base = emptyQuestionnaireAnswers() if (!saved || typeof saved !== 'object') return base for (const k of Object.keys(base)) { if (!(k in saved)) continue const v = saved[k] if (Array.isArray(base[k])) base[k] = Array.isArray(v) ? v.map(String) : [] else if (base[k] === null) base[k] = v === true ? true : v === false ? false : null else base[k] = v === null || v === undefined ? '' : v } return base } export const qLabel = (q: { de: string; en: string }, lang: QLang) => (lang === 'en' ? q.en : q.de) /** Human-readable answer for one question (PDF filled mode, activity summary). */ export const formatQuestionnaireAnswer = (q: QQuestion, answers: Record, lang: QLang): string => { const en = lang === 'en' const v = answers?.[q.id] let out = '' if (q.type === 'bool') out = v === true ? (en ? 'Yes' : 'Ja') : v === false ? (en ? 'No' : 'Nein') : '' else if (q.type === 'choice') out = (q.options || []).filter((o) => o.key === v).map((o) => qLabel(o, lang)).join('') else if (q.type === 'multi') out = (q.options || []).filter((o) => Array.isArray(v) && v.includes(o.key)).map((o) => qLabel(o, lang)).join(', ') else if (q.type === 'number') { if (v !== '' && v !== null && v !== undefined && !isNaN(Number(v))) { out = Number(v).toLocaleString(en ? 'en-GB' : 'de-DE') if (q.unitChoice) { const u = q.unitChoice.find((o) => o.key === answers?.[`${q.id}_unit`]) if (u) out += ` ${qLabel(u, lang)}` } else if (q.unit) out += ` ${q.unit.replace('Monat', en ? 'month' : 'Monat')}` } } else out = String(v ?? '').trim() const detail = q.detail ? String(answers?.[`${q.id}_detail`] ?? '').trim() : '' if (detail) out = out ? `${out} — ${detail}` : detail return out } /** Count of answered questions (for the modal progress chip). */ export const questionnaireProgress = (answers: Record): { answered: number; total: number } => { const qs = questionnaireQuestions().filter((q) => q.id !== 'notes') let answered = 0 for (const q of qs) { const v = answers?.[q.id] const has = q.type === 'bool' ? v === true || v === false : q.type === 'multi' ? Array.isArray(v) && v.length > 0 : v !== '' && v !== null && v !== undefined if (has) answered++ } return { answered, total: qs.length } } /** File name of the rendered PDF. */ export const questionnaireFilename = (lang: QLang, company: string, mode: 'blank' | 'filled', date = new Date()): string => { const c = String(company || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^A-Za-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 40) const d = date.toISOString().slice(0, 10) const base = lang === 'en' ? 'Questionnaire_Initial_Call' : 'Fragebogen_Erstgespraech' if (mode === 'blank') return `${base}_${lang === 'en' ? 'blank' : 'leer'}${c ? '_' + c : ''}.pdf` return `${base}${c ? '_' + c : ''}_${d}.pdf` }