import React, { useState } from 'react'; import { render, Box, Text, useApp, useInput } from 'ink'; import TextInput from 'ink-text-input'; import chalk from 'chalk'; import { getArticle } from '../src/services/article/getArticle.js'; import { searchArticles } from '../src/services/article/searchArticles.js'; import { closePool } from '../src/db/mssqlClient.js'; import { bruttoFromNetto, loadSteuerklassen } from '../src/services/misc/getSteuer.js'; import '../src/config/env.js'; const e = React.createElement; const MODES = { SEARCH: 'search', LOADING: 'loading', LIST: 'list', RESULT: 'result', ERROR: 'error', }; const VISIBLE_ROWS = 15; const eur = (value) => { if (value == null || Number.isNaN(Number(value))) { return '—'; } return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(value); }; function formatPrice(row, steuerklassen) { if (row.fVKNEtto == null) { return '—'; } const brutto = bruttoFromNetto(row.fVKNEtto, row.kSteuerklasse, steuerklassen); return brutto == null ? '—' : eur(brutto); } function hasValue(value) { if (value == null || value === '' || value === 0) { return false; } if (Array.isArray(value)) { return value.length > 0; } return true; } function DetailRow({ label, value, alwaysShow = false }) { if (!alwaysShow && !hasValue(value) && value !== 0 && value !== '0') { return null; } const text = value == null || value === '' ? '—' : String(value); return e(Box, null, e(Text, { dimColor: true }, `${label.padEnd(14)} `), e(Text, null, text) ); } function Section({ title, children }) { const items = React.Children.toArray(children).filter(Boolean); if (!items.length) { return null; } return e(Box, { flexDirection: 'column', marginTop: 1 }, e(Text, null, chalk.cyan.bold(title)), ...items ); } function ArticleDetail({ article, steuerklassen }) { const brutto = bruttoFromNetto(article.fVKNEtto, article.kSteuerklasse, steuerklassen); const aktiv = article.cAktiv === 'Y' ? chalk.green('active') : article.cAktiv === 'N' ? chalk.red('inactive') : article.cAktiv; return e(Box, { flexDirection: 'column' }, e(Text, null, chalk.white.bold(article.cName ?? '—')), e(Text, { dimColor: true }, article.cArtNr ?? '—'), e(Section, { title: 'Overview' }, e(DetailRow, { label: 'Artikel-ID', value: article.kArtikel }), e(DetailRow, { label: 'Status', value: aktiv }), e(DetailRow, { label: 'Hersteller', value: article.cHersteller }), e(DetailRow, { label: 'Barcode', value: article.cBarcode }), article.EANs?.length ? e(DetailRow, { label: 'EANs', value: article.EANs.map((row) => row.cEAN).join(', ') }) : null ), e(Section, { title: 'Pricing' }, e(DetailRow, { label: 'VK brutto', value: brutto != null ? eur(brutto) : null }), e(DetailRow, { label: 'VK netto', value: article.fVKNEtto != null ? eur(article.fVKNEtto) : null }), e(DetailRow, { label: 'Steuerklasse', value: article.kSteuerklasse }), hasValue(article.fUVP) ? e(DetailRow, { label: 'UVP', value: eur(article.fUVP) }) : null, article.staffel?.length ? e(Box, { flexDirection: 'column', marginTop: 0 }, e(Text, { dimColor: true }, 'Staffel'), ...article.staffel.map((row, index) => e(Text, { key: index, dimColor: true }, ` ab ${row.menge} ${row.fVKBrutto} €`) ) ) : null ), e(Section, { title: 'Stock' }, e(DetailRow, { label: 'Lager 1', value: article.fBestand, alwaysShow: true }), hasValue(article.garage) ? e(DetailRow, { label: 'Garage', value: article.garage }) : null, hasValue(article.keller) ? e(DetailRow, { label: 'Keller', value: article.keller }) : null, article.fAndereLager?.length ? e(DetailRow, { label: 'Andere Lager', value: article.fAndereLager.map((row) => row.fSumme).join(', '), }) : null, article.incoming?.length ? e(Text, { dimColor: true }, ` ${article.incoming.length} open supplier order(s)`) : null ), e(Section, { title: 'Product' }, e(DetailRow, { label: 'Versandklasse', value: article.tVersandklassecName ?? article.kVersandklasse }), hasValue(article.fGewicht) ? e(DetailRow, { label: 'Gewicht', value: `${article.fGewicht} kg` }) : null, hasValue(article.shops) ? e(DetailRow, { label: 'Shops', value: article.shops }) : null, article.KATs?.length ? e(DetailRow, { label: 'Kategorien', value: article.KATs.map((row) => row.kKategorie).join(', ') }) : null, article.merkmale?.length ? e(Box, { flexDirection: 'column' }, e(Text, { dimColor: true }, 'Merkmale'), ...article.merkmale.map((row, index) => e(Text, { key: index, dimColor: true }, ` ${row.cName}: ${row.cWert}`) ) ) : null ), article.lief?.length ? e(Section, { title: `Suppliers (${article.lief.length})` }, ...article.lief.slice(0, 8).map((row, index) => e(Text, { key: index, dimColor: true }, ` ${row.nStandard ? chalk.yellow('* ') : ' '}${row.cFirma ?? '—'} ${row.cLiefArtNr ?? '—'} EK ${eur(row.fEKNetto)}` ) ), article.lief.length > 8 ? e(Text, { dimColor: true }, ` … +${article.lief.length - 8} more`) : null ) : null, article.children?.length ? e(Section, { title: `Variants (${article.children.length})` }, ...article.children.slice(0, 6).map((row, index) => e(Text, { key: index, dimColor: true }, ` ${row.cArtNr} ${row.cName ?? '—'}` ) ) ) : null, article.liste?.length ? e(Section, { title: `Stückliste (${article.liste.length})` }, ...article.liste.map((row, index) => e(Text, { key: index, dimColor: true }, ` ${row.cArtNr} × ${row.fAnzahl} ${row.cName ?? '—'}` ) ) ) : null, article.orders?.length ? e(Section, { title: 'Open orders' }, ...article.orders.map((row, index) => e(Text, { key: index, dimColor: true }, ` ${row.cType} ${row.cNote ?? ''}`) ) ) : null, hasValue(article.cBeschreibung) ? e(Section, { title: 'Description' }, e(Text, null, truncateForDisplay(article.cBeschreibung, 240)) ) : null ); } function truncateForDisplay(value, max = 80) { if (typeof value !== 'string' || value.length <= max) { return value; } return `${value.slice(0, max)}… (${value.length} chars)`; } function formatListRow(row) { const stock = row.stock ?? '—'; const price = row.price == null ? '—' : eur(row.price); const name = row.name ?? '—'; return `${row.sku ?? '—'} ${stock} ${price} ${name}`; } const ResultList = ({ rows, onSelect, onBack }) => { const [selected, setSelected] = useState(0); const windowStart = Math.max(0, Math.min(selected - Math.floor(VISIBLE_ROWS / 2), rows.length - VISIBLE_ROWS)); const visible = rows.slice(windowStart, windowStart + VISIBLE_ROWS); useInput((_input, key) => { if (key.escape) { onBack(); return; } if (key.upArrow) { setSelected((s) => Math.max(0, s - 1)); } if (key.downArrow) { setSelected((s) => Math.min(rows.length - 1, s + 1)); } if (key.return && rows[selected]) { onSelect(rows[selected]); } }); return e(Box, { flexDirection: 'column' }, e(Text, null, chalk.cyan.bold(`${rows.length} result(s) — ↑↓ select, Enter open, Esc back`)), visible.map((row, index) => { const absoluteIndex = windowStart + index; const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' '; const label = absoluteIndex === selected ? chalk.cyan.bold(formatListRow(row)) : formatListRow(row); return e(Text, { key: row.id ?? absoluteIndex }, prefix, label); }), rows.length > VISIBLE_ROWS ? e(Text, null, chalk.gray(`Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`)) : null ); }; const App = ({ steuerklassen }) => { const { exit } = useApp(); const [mode, setMode] = useState(MODES.SEARCH); const [target, setTarget] = useState(''); const [rows, setRows] = useState([]); const [article, setArticle] = useState(null); const [error, setError] = useState(''); const [loadingLabel, setLoadingLabel] = useState('Searching…'); const quit = async (code = 0) => { try { await closePool(); } catch { // ignore cleanup errors } exit(); process.exit(code); }; const loadArticle = async (cArtNr) => { setLoadingLabel(`Loading ${cArtNr}…`); setMode(MODES.LOADING); setError(''); try { const result = await getArticle(cArtNr); if (!result) { setError(`Article not found: ${cArtNr}`); setMode(MODES.ERROR); return; } setArticle(result); setMode(MODES.RESULT); } catch (err) { setError(err.message); setMode(MODES.ERROR); } }; const handleSearch = async () => { const trimmed = target.trim(); if (!trimmed) { setError('Search terms are required'); setMode(MODES.ERROR); return; } setLoadingLabel('Searching…'); setMode(MODES.LOADING); setError(''); try { const { recordset } = await searchArticles(trimmed); if (!recordset.length) { setError(`No articles found for: ${trimmed}`); setMode(MODES.ERROR); return; } if (recordset.length === 1) { await loadArticle(recordset[0].sku); return; } setRows(recordset); setMode(MODES.LIST); } catch (err) { setError(err.message); setMode(MODES.ERROR); } }; const backToSearch = () => { setRows([]); setArticle(null); setError(''); setMode(MODES.SEARCH); }; const resetSearch = () => { setTarget(''); backToSearch(); }; if (mode === MODES.LOADING) { return e(Text, null, chalk.yellow(loadingLabel)); } if (mode === MODES.ERROR) { return e(Box, { flexDirection: 'column' }, e(Text, null, chalk.red(`Error: ${error}`)), e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Press any key to try again'))), e(ExitOnKey, { onExit: backToSearch }) ); } if (mode === MODES.LIST) { return e(ResultList, { rows, onSelect: (row) => loadArticle(row.sku), onBack: backToSearch, }); } const backToList = () => { setArticle(null); setError(''); setMode(rows.length ? MODES.LIST : MODES.SEARCH); }; if (mode === MODES.RESULT) { const footer = rows.length ? 'Esc = back to results, Enter = new search, q = quit' : 'Enter = new search, q = quit'; return e(Box, { flexDirection: 'column' }, e(ArticleDetail, { article, steuerklassen }), e(Box, { marginTop: 1 }, e(Text, null, chalk.gray(footer))), e(ResultInput, { onAgain: resetSearch, onBack: backToList, onQuit: () => quit(0), canGoBack: rows.length > 0, }) ); } return e(Box, { flexDirection: 'column' }, e(Box, { marginBottom: 1 }, e(Text, null, chalk.cyan.bold('=== Article Search ===')) ), e(Text, null, chalk.gray('Partial match on name, number, EAN, barcode, supplier number')), e(Box, { marginTop: 1 }, e(Text, null, 'Target: '), e(TextInput, { value: target, onChange: setTarget, onSubmit: handleSearch }) ), e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Enter to search, Ctrl+C to quit'))) ); }; const ResultInput = ({ onAgain, onBack, onQuit, canGoBack }) => { useInput((_input, key) => { if (key.escape && canGoBack) { onBack(); return; } if (_input === 'q' || (key.ctrl && _input === 'c')) { onQuit(); return; } if (key.return) { onAgain(); } }); return null; }; const ExitOnKey = ({ onExit }) => { useInput(onExit); return null; }; const steuerklassen = await loadSteuerklassen(); render(e(App, { steuerklassen }));