402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
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 }));
|