This commit is contained in:
seb
2026-07-31 05:54:13 +02:00
parent 6db7d4d75c
commit db8b192094
32 changed files with 100 additions and 2048 deletions

View File

@@ -1,90 +0,0 @@
import React, { useState } from 'react';
import { render, Box, Text, useApp, useInput } from 'ink';
import chalk from 'chalk';
import { getCategories } from '../src/services/misc/getCategories.js';
import { closePool } from '../src/db/mssqlClient.js';
import '../src/config/env.js';
const e = React.createElement;
const VISIBLE_ROWS = 20;
function flattenTree(nodes, depth = 0, out = []) {
for (const node of nodes) {
out.push({ ...node, depth });
flattenTree(node.children, depth + 1, out);
}
return out;
}
function formatRow(row) {
const indent = ' '.repeat(row.depth);
const count = chalk.gray(` (${row.count})`);
return `${indent}${row.cName ?? '—'}${count} ${chalk.dim(String(row.kKategorie))}`;
}
const CategoryBrowser = ({ rows }) => {
const { exit } = useApp();
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);
const current = rows[selected];
const quit = () => {
closePool()
.catch(() => {})
.finally(() => {
exit();
process.exit(0);
});
};
useInput((input, key) => {
if (input === 'q' || (key.ctrl && input === 'c')) {
quit();
return;
}
if (key.upArrow) {
setSelected((s) => Math.max(0, s - 1));
}
if (key.downArrow) {
setSelected((s) => Math.min(rows.length - 1, s + 1));
}
});
return e(Box, { flexDirection: 'column' },
e(Text, null, chalk.cyan.bold(`=== Categories (${rows.length}) ===`)),
e(Box, { marginTop: 1, flexDirection: 'column' },
visible.map((row, index) => {
const absoluteIndex = windowStart + index;
const prefix = absoluteIndex === selected ? chalk.cyan('> ') : ' ';
const label = absoluteIndex === selected
? chalk.cyan.bold(formatRow(row))
: formatRow(row);
return e(Text, { key: row.kKategorie }, prefix, label);
})
),
rows.length > VISIBLE_ROWS
? e(Text, { dimColor: true }, `Showing ${windowStart + 1}-${windowStart + visible.length} of ${rows.length}`)
: null,
current
? e(Box, { marginTop: 1, flexDirection: 'column' },
e(Text, { dimColor: true },
`kKategorie: ${current.kKategorie} parent: ${current.kOberKategorie} sort: ${current.nSort ?? '—'} articles: ${current.count}`
)
)
: null,
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('↑↓ navigate, q exit')))
);
};
try {
const { tree } = await getCategories();
const rows = flattenTree(tree);
render(e(CategoryBrowser, { rows }));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
await closePool().catch(() => {});
process.exit(1);
}

View File

@@ -1,52 +0,0 @@
import React from 'react';
import { render, Box, Text, useApp, useInput } from 'ink';
import chalk from 'chalk';
import { getSteuer } from '../src/services/misc/getSteuer.js';
import { closePool } from '../src/db/mssqlClient.js';
import '../src/config/env.js';
const e = React.createElement;
function SteuerTable({ steuerklassen }) {
return e(Box, { flexDirection: 'column' },
e(Text, null, chalk.cyan.bold('=== Steuerklassen ===')),
e(Box, { marginTop: 1, flexDirection: 'column' },
e(Text, { dimColor: true }, ' ID Rate Name'),
...steuerklassen.map((row) =>
e(Text, { key: row.kSteuerklasse },
' ',
chalk.white(String(row.kSteuerklasse).padEnd(4)),
chalk.yellow(`${String(row.fSteuersatz).padStart(3)}%`.padEnd(8)),
row.cName ?? '—',
row.nStandard === 1 ? chalk.gray(' (standard)') : ''
)
)
),
e(Box, { marginTop: 1 }, e(Text, null, chalk.gray('Press any key to exit')))
);
}
const App = ({ steuerklassen }) => {
const { exit } = useApp();
useInput(() => {
closePool()
.catch(() => {})
.finally(() => {
exit();
process.exit(0);
});
});
return e(SteuerTable, { steuerklassen });
};
try {
const { steuerklassen } = await getSteuer();
render(e(App, { steuerklassen }));
} catch (error) {
console.error(chalk.red(`Error: ${error.message}`));
await closePool().catch(() => {});
process.exit(1);
}

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { render, Box, Text, useApp, useInput } from 'ink';
import TextInput from 'ink-text-input';
import chalk from 'chalk';
@@ -48,6 +48,17 @@ const App = () => {
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [emails, setEmails] = useState(null);
useEffect(() => {
if (mode !== MODES.LIST) {
return;
}
setEmails(null);
googleAuth.listAllowedEmails()
.then(setEmails)
.catch((err) => finish(false, err.message));
}, [mode]);
const finish = (success, text) => {
if (success) {
@@ -72,28 +83,28 @@ const App = () => {
setMode(action);
};
const handleAdd = () => {
const handleAdd = async () => {
try {
const trimmed = email.trim();
if (!trimmed || !trimmed.includes('@')) {
finish(false, 'Invalid email address');
return;
}
googleAuth.addAllowedEmail(trimmed);
await googleAuth.addAllowedEmail(trimmed);
finish(true, `Added ${trimmed.toLowerCase()} to google_auth`);
} catch (err) {
finish(false, err.message);
}
};
const handleRemove = () => {
const handleRemove = async () => {
try {
const trimmed = email.trim();
if (!trimmed) {
finish(false, 'Email is required');
return;
}
const removed = googleAuth.removeAllowedEmail(trimmed);
const removed = await googleAuth.removeAllowedEmail(trimmed);
finish(true, removed ? `Removed ${trimmed.toLowerCase()}` : `Email not found: ${trimmed}`);
} catch (err) {
finish(false, err.message);
@@ -107,7 +118,9 @@ const App = () => {
return e(Text, null, chalk.red(`Error: ${error}`));
}
if (mode === MODES.LIST) {
const emails = googleAuth.listAllowedEmails();
if (!emails) {
return e(Text, null, chalk.gray('Loading...'));
}
if (emails.length === 0) {
finish(true, 'No allowed emails in google_auth');
return e(Text, null, chalk.yellow('No allowed emails...'));

View File

@@ -1,401 +0,0 @@
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 }));