This commit is contained in:
seb
2026-06-24 09:37:34 +02:00
parent 977ece3099
commit 185b68fe52
16 changed files with 1312 additions and 89 deletions

401
scripts/searchArticles.js Normal file
View File

@@ -0,0 +1,401 @@
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 { getBrutto } from '../src/utils/tax.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) {
if (row.fVKNEtto == null) {
return '—';
}
return eur(getBrutto(row.fVKNEtto, row.kSteuerklasse));
}
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 }) {
const brutto = article.fVKNEtto != null
? getBrutto(article.fVKNEtto, article.kSteuerklasse)
: null;
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.fBestand ?? '—';
const price = formatPrice(row);
const name = row.cName ?? '—';
return `${row.cArtNr ?? '—'} ${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.kArtikel ?? absoluteIndex }, prefix, label);
}),
rows.length > VISIBLE_ROWS
? e(Text, null, chalk.gray(`Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`))
: null
);
};
const App = () => {
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].cArtNr);
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.cArtNr),
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 }),
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;
};
render(e(App));