Files
dashback/scripts/searchArticles.js
2026-06-29 10:23:32 +02:00

402 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }));