This commit is contained in:
seb
2026-08-03 07:13:42 +02:00
parent 4db4948090
commit f302eb9e67
9 changed files with 985 additions and 51 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ dist-ssr
*.local *.local
.DS_Store .DS_Store
certs/ certs/
.dashfront-token

177
src/api/api-tester.mjs Normal file
View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
/**
* API Tester CLI — reuses src/api/index.js directly.
*
* Usage:
* node src/api/api-tester.mjs # interactive REPL
* node src/api/api-tester.mjs <method> [args...] # one-shot call
*
* Auth:
* 1. Copy your session cookie from the browser DevTools (name: "token", value: the JWT)
* 2. Paste it into ~/.dashfront-token (or pass --token-file <path>)
* 3. Or pass --token <jwt-string> on the command line
*
* Examples:
* node src/api/api-tester.mjs posterminal.list
* node src/api/api-tester.mjs category.getTree
* node src/api/api-tester.mjs posterminal.create '{"name":"Kassa 3"}'
* node src/api/api-tester.mjs auth.me
*/
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { resolve } from 'node:path';
import { createInterface } from 'node:readline';
// ── CLI args ─────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
function flag(name) {
const idx = args.indexOf(name);
if (idx === -1) return null;
const val = args[idx + 1];
args.splice(idx, 2);
return val;
}
const BASE_URL = flag('--base') || 'http://localhost:4208';
const tokenFileArg = flag('--token-file');
const tokenArg = flag('--token');
function loadToken() {
if (tokenArg) return tokenArg;
const paths = [
tokenFileArg,
resolve(homedir(), '.dashfront-token'),
'./.dashfront-token',
].filter(Boolean);
for (const p of paths) {
try {
const t = readFileSync(p, 'utf-8').trim();
if (t) return t;
} catch { /* ignore */ }
}
console.error('No token found. Options:');
console.error(' 1. Place JWT in ~/.dashfront-token');
console.error(' 2. Pass --token <jwt>');
console.error(' 3. Pass --token-file <path>');
process.exit(1);
}
const TOKEN = loadToken();
// ── Patch globals so src/api/index.js works in Node ──────────────────────────
// Fake browser globals so src/api/index.js works in Node
globalThis.window = {
location: { origin: BASE_URL },
};
globalThis.document = {
get cookie() { return ''; },
set cookie(_v) { /* no-op */ },
};
// Patch fetch to send the session cookie
const originalFetch = globalThis.fetch;
globalThis.fetch = async (input, init) => {
const url = typeof input === 'string' ? input : input?.url ?? String(input);
const fullUrl = url.startsWith('http') ? url : `${BASE_URL}${url}`;
const headers = new Headers(init?.headers);
headers.set('Cookie', `token=${TOKEN}`);
const res = await originalFetch(fullUrl, { ...init, headers });
return res;
};
// ── Load the API module ──────────────────────────────────────────────────────
const api = (await import('./index.js')).default;
// ── Helpers ──────────────────────────────────────────────────────────────────
const flatten = (obj, prefix = '') => {
const entries = [];
for (const [key, val] of Object.entries(obj)) {
const path = prefix ? `${prefix}.${key}` : key;
if (typeof val === 'function') {
entries.push([path, val]);
} else if (typeof val === 'object' && val !== null) {
entries.push(...flatten(val, path));
}
}
return entries;
};
const methods = new Map(flatten(api));
function parseArg(raw) {
const trimmed = raw.trim();
if (trimmed === 'undefined') return undefined;
if (trimmed === 'null') return null;
if (trimmed === 'true') return true;
if (trimmed === 'false') return false;
try { return JSON.parse(trimmed); } catch { return trimmed; }
}
function invoke(methodPath, argStrings = []) {
const fn = methods.get(methodPath);
if (!fn) {
console.error(`Unknown method: ${methodPath}`);
console.error(`Available: ${[...methods.keys()].join(', ')}`);
return;
}
const args = argStrings.map(parseArg);
return fn(...args)
.then((result) => {
console.log(JSON.stringify(result, null, 2));
return result;
})
.catch((err) => {
console.error(`[ERROR] ${err.message}`);
if (err.status) console.error(` status: ${err.status}`);
if (err.data) console.error(` data:`, err.data);
});
}
// ── One-shot mode ────────────────────────────────────────────────────────────
if (args.length > 0) {
const [method, ...rest] = args;
await invoke(method, rest);
process.exit(0);
}
// ── Interactive REPL ─────────────────────────────────────────────────────────
console.log(`API Tester — ${BASE_URL}`);
console.log(`Token: ${TOKEN.slice(0, 20)}…`);
console.log(`Methods: ${[...methods.keys()].join(', ')}`);
console.log('Type a method call, or "quit" to exit.\n');
const rl = createInterface({ input: process.stdin, output: process.stdout });
function prompt() {
rl.question('api> ', async (line) => {
const trimmed = line.trim();
if (!trimmed || trimmed === 'quit' || trimmed === 'exit') {
rl.close();
process.exit(0);
}
// Parse: method arg1 arg2 ...
// Arguments are space-separated; wrap objects in quotes: method '{"key":"val"}'
const parts = trimmed.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
const [method, ...rest] = parts.map((p) => p.replace(/^"|"$/g, ''));
await invoke(method, rest);
prompt();
});
}
prompt();

View File

@@ -371,6 +371,20 @@ export const category = {
body: { parentId }, body: { parentId },
}); });
}, },
/**
* PATCH /api/category/:id
* Body: { pos: boolean } — toggles the POS (Point of Sale) flag.
* → 200 { category: { id, pos } }
*
* PROPOSED — backend needs to implement this field.
*/
updatePos(categoryId, pos) {
return http(`/api/category/${categoryId}`, {
method: 'PATCH',
body: { pos },
});
},
}; };
/** /**
@@ -437,6 +451,83 @@ export const company = {
}, },
}; };
export const posterminal = {
/**
* GET /api/posterminal → { posterminals: [{ id, name, token, pairingpin }] }
* List all terminals for the selected company.
*/
list() {
return http('/api/posterminal');
},
/**
* PUT /api/posterminal
* Body: { name: string }
* Creates a new terminal (auto-generates 6-digit pin).
* → 200 { posterminal: { id, name, token, pairingpin } }
*/
create({ name }) {
return http('/api/posterminal', {
method: 'PUT',
body: { name },
});
},
/**
* DELETE /api/posterminal/:id → 204
* Deletes a terminal.
*/
delete(terminalId) {
return http(`/api/posterminal/${terminalId}`, {
method: 'DELETE',
});
},
/**
* PUT /api/posterminal/:id/category/:categoryId
* Enable a category on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
enableCategory(terminalId, categoryId) {
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
method: 'PUT',
});
},
/**
* DELETE /api/posterminal/:id/category/:categoryId
* Disable a category on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
disableCategory(terminalId, categoryId) {
return http(`/api/posterminal/${terminalId}/category/${categoryId}`, {
method: 'DELETE',
});
},
/**
* PUT /api/posterminal/:id/all
* Enable all categories on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
enableAllCategories(terminalId) {
return http(`/api/posterminal/${terminalId}/all`, {
method: 'PUT',
});
},
/**
* DELETE /api/posterminal/:id/all
* Disable all categories on a terminal.
* → 200 { id: number, categoryIds: number[] }
*/
disableAllCategories(terminalId) {
return http(`/api/posterminal/${terminalId}/all`, {
method: 'DELETE',
});
},
};
export const auth = { export const auth = {
async me() { async me() {
try { try {
@@ -488,4 +579,4 @@ export const auth = {
}, },
}; };
export default { auth, article, brand, category, company, thumb, unit }; export default { auth, article, brand, category, company, posterminal, thumb, unit };

View File

@@ -8,13 +8,22 @@ import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions'; import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle'; import DialogTitle from '@mui/material/DialogTitle';
import Divider from '@mui/material/Divider';
import FormControl from '@mui/material/FormControl'; import FormControl from '@mui/material/FormControl';
import FormLabel from '@mui/material/FormLabel'; import FormLabel from '@mui/material/FormLabel';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment'; import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction';
import ListItemText from '@mui/material/ListItemText';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Switch from '@mui/material/Switch';
import TextField from '@mui/material/TextField'; import TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton'; import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import BackendError from './BackendError'; import BackendError from './BackendError';
import SingleLineTextEdit from './SingleLineTextEdit'; import SingleLineTextEdit from './SingleLineTextEdit';
@@ -25,8 +34,18 @@ import {
selectCurrentCompany, selectCurrentCompany,
updateCompany as updateCompanyThunk, updateCompany as updateCompanyThunk,
} from '../store/companySlice'; } from '../store/companySlice';
import {
loadPosterminals,
createPosterminal,
deletePosterminal,
enableAllTerminalCategories,
selectPosterminals,
selectPosterminalStatus,
} from '../store/posterminalSlice';
import { loadCategoryTree } from '../store/categorySlice';
import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm'; import { ISO639_OPTIONS, parseTaxRate } from '../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError'; import { backendErrorFrom, isBackendFailure } from '../utils/backendError';
import { notifyError } from '../utils/notify';
class CompanySettingsDialog extends Component { class CompanySettingsDialog extends Component {
constructor(props) { constructor(props) {
@@ -38,9 +57,15 @@ class CompanySettingsDialog extends Component {
iso639: 'de', iso639: 'de',
taxrate: '', taxrate: '',
reducedtaxrate: '', reducedtaxrate: '',
newTerminalName: '',
creatingTerminal: false,
enableAllForNew: true,
}; };
this.handleSubmit = this.handleSubmit.bind(this); this.handleSubmit = this.handleSubmit.bind(this);
this.syncFormFromCompany = this.syncFormFromCompany.bind(this); this.syncFormFromCompany = this.syncFormFromCompany.bind(this);
this.handleCreateTerminal = this.handleCreateTerminal.bind(this);
this.handleDeleteTerminal = this.handleDeleteTerminal.bind(this);
this.handleEnableAllCategories = this.handleEnableAllCategories.bind(this);
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@@ -52,6 +77,10 @@ class CompanySettingsDialog extends Component {
} else if (companyStatus !== 'loading') { } else if (companyStatus !== 'loading') {
void this.props.loadCurrentCompany(); void this.props.loadCurrentCompany();
} }
// Load posterminals when dialog opens
if (this.props.posterminalStatus !== 'loading') {
void this.props.loadPosterminals();
}
} }
if (open && company && company !== prevProps.company) { if (open && company && company !== prevProps.company) {
this.syncFormFromCompany(company); this.syncFormFromCompany(company);
@@ -67,6 +96,56 @@ class CompanySettingsDialog extends Component {
}); });
} }
async handleCreateTerminal() {
const { newTerminalName, enableAllForNew } = this.state;
const trimmed = newTerminalName.trim();
if (!trimmed) {
enqueueSnackbar('Terminalname ist erforderlich.', { variant: 'error' });
return;
}
this.setState({ creatingTerminal: true });
try {
const terminal = await this.props.createPosterminal({ name: trimmed }).unwrap();
// If "enable all" toggle is on, enable all categories for this terminal
if (enableAllForNew && terminal.id) {
await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
// Reload category tree so tiles reflect the updated posterminalIds
this.props.loadCategoryTree();
}
this.setState({ newTerminalName: '' });
} catch (err) {
notifyError(err);
} finally {
this.setState({ creatingTerminal: false });
}
}
async handleDeleteTerminal(terminal) {
try {
await this.props.deletePosterminal({ terminalId: terminal.id }).unwrap();
} catch (err) {
notifyError(err);
}
}
async handleEnableAllCategories(terminal) {
try {
const hasCategories = terminal.categoryIds && terminal.categoryIds.length > 0;
if (hasCategories) {
await this.props.disableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
enqueueSnackbar('Alle Kategorien für Terminal deaktiviert.', { variant: 'success' });
} else {
await this.props.enableAllTerminalCategories({ terminalId: terminal.id }).unwrap();
enqueueSnackbar('Alle Kategorien für Terminal aktiviert.', { variant: 'success' });
}
} catch (err) {
notifyError(err);
}
}
async handleSubmit(event) { async handleSubmit(event) {
event.preventDefault(); event.preventDefault();
const { onClose } = this.props; const { onClose } = this.props;
@@ -200,6 +279,77 @@ class CompanySettingsDialog extends Component {
}} }}
/> />
</Stack> </Stack>
<Divider sx={{ my: 1 }} />
<Box>
{this.props.posterminalStatus === 'loading' ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
<CircularProgress size={24} />
</Box>
) : (
<>
<List dense sx={{ py: 0 }}>
{this.props.terminals.map((terminal) => (
<ListItem
key={terminal.id}
sx={{ px: 0, py: 0.5 }}
>
<PointOfSaleIcon sx={{ mr: 1, color: 'text.secondary', fontSize: 20 }} />
<ListItemText
primary={terminal.name}
secondary={`PIN: ${terminal.pairingpin}`}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
size="small"
onClick={() => this.handleDeleteTerminal(terminal)}
disabled={busy || this.state.creatingTerminal}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
size="small"
placeholder="Neues Terminal..."
value={this.state.newTerminalName}
onChange={(e) => this.setState({ newTerminalName: e.target.value })}
disabled={busy || this.state.creatingTerminal}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.handleCreateTerminal();
}
}}
sx={{ flex: 1 }}
/>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<PointOfSaleIcon sx={{ fontSize: 18, color: 'text.secondary' }} />
<Switch
size="small"
checked={this.state.enableAllForNew}
onChange={(e) => this.setState({ enableAllForNew: e.target.checked })}
disabled={busy || this.state.creatingTerminal}
/>
</Box>
<Button
variant="contained"
size="small"
onClick={this.handleCreateTerminal}
disabled={busy || this.state.creatingTerminal || !this.state.newTerminalName.trim()}
>
Hinzufügen
</Button>
</Box>
</>
)}
</Box>
</Stack> </Stack>
)} )}
</DialogContent> </DialogContent>
@@ -221,11 +371,18 @@ const mapStateToProps = (state) => ({
user: selectAuthUser(state), user: selectAuthUser(state),
company: selectCurrentCompany(state), company: selectCurrentCompany(state),
companyStatus: selectCompanyStatus(state), companyStatus: selectCompanyStatus(state),
terminals: selectPosterminals(state),
posterminalStatus: selectPosterminalStatus(state),
}); });
const mapDispatchToProps = (dispatch) => ({ const mapDispatchToProps = (dispatch) => ({
loadCurrentCompany: () => dispatch(loadCurrentCompany()), loadCurrentCompany: () => dispatch(loadCurrentCompany()),
updateCompany: (payload) => dispatch(updateCompanyThunk(payload)), updateCompany: (payload) => dispatch(updateCompanyThunk(payload)),
loadPosterminals: () => dispatch(loadPosterminals()),
createPosterminal: (payload) => dispatch(createPosterminal(payload)),
deletePosterminal: (payload) => dispatch(deletePosterminal(payload)),
enableAllTerminalCategories: (payload) => dispatch(enableAllTerminalCategories(payload)),
loadCategoryTree: () => dispatch(loadCategoryTree()),
}); });
export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog); export default connect(mapStateToProps, mapDispatchToProps)(CompanySettingsDialog);

View File

@@ -2,18 +2,22 @@ import { Component } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import Switch from '@mui/material/Switch';
import DragHandleIcon from '@mui/icons-material/DragHandle'; import DragHandleIcon from '@mui/icons-material/DragHandle';
import AccountTreeIcon from '@mui/icons-material/AccountTree'; import AccountTreeIcon from '@mui/icons-material/AccountTree';
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'; import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import { Tile, tileBaseSx } from './Tile'; import SettingsIcon from '@mui/icons-material/Settings';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import { tileBaseSx } from './Tile';
/** /**
* Top edit band: left = reorder handle (SortableJS), right = drop-into-subcategory * Top row (1/3): sort handle | settings toggle | move handle.
* handle (Shopify Draggable Droppable).
*/ */
function TopBand() { function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
// settingsTab >= 0 means we're on a terminal tab
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
return ( return (
<Box <Box
sx={{ sx={{
@@ -36,6 +40,20 @@ function TopBand() {
> >
<DragHandleIcon sx={{ fontSize: 36 }} /> <DragHandleIcon sx={{ fontSize: 36 }} />
</IconButton> </IconButton>
<IconButton
aria-label="Einstellungen"
disableRipple
onClick={onSettingsClick}
sx={{
color: isTerminalMode ? 'primary.main' : 'action.active',
}}
>
{isTerminalMode ? (
<PointOfSaleIcon sx={{ fontSize: 28 }} />
) : (
<SettingsIcon sx={{ fontSize: 28 }} />
)}
</IconButton>
<IconButton <IconButton
className="droppable-handle" className="droppable-handle"
aria-label="In Unterkategorie ablegen" aria-label="In Unterkategorie ablegen"
@@ -49,10 +67,48 @@ function TopBand() {
} }
/** /**
* Shared grey name band spanning the middle third. * Middle row (1/3): gray band — TextField or terminal toggle.
*/ */
function NameBand({ label, nameInput }) { function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle }) {
if (label == null) return null; console.log('[MiddleBand] settingsTab:', settingsTab, 'terminals:', terminals?.length, 'categoryPosterminalIds:', categoryPosterminalIds);
// Show terminal-specific toggle when on a terminal tab
if (settingsTab >= 0 && settingsTab < terminals.length) {
const terminal = terminals[settingsTab];
const isEnabled = categoryPosterminalIds.includes(terminal.id);
return (
<Box
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
px: 1,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PointOfSaleIcon sx={{ fontSize: 20, color: 'text.secondary' }} />
<Typography
variant="caption"
noWrap
sx={{ maxWidth: 80, color: 'text.secondary' }}
>
{terminal.name}
</Typography>
<Switch
checked={isEnabled}
onChange={(e) => onTerminalToggle?.(terminal.id, e.target.checked)}
size="small"
/>
</Box>
</Box>
);
}
return ( return (
<Box <Box
sx={{ sx={{
@@ -68,24 +124,17 @@ function NameBand({ label, nameInput }) {
px: 1, px: 1,
}} }}
> >
{nameInput ?? ( {nameInput}
<Typography
variant="body2"
fontWeight={600}
noWrap
sx={{ lineHeight: 1.2, color: 'text.primary' }}
>
{label}
</Typography>
)}
</Box> </Box>
); );
} }
/** /**
* Bottom edit band with left/right chevron reorder buttons and a center delete. * Bottom row (1/3): split into two sub-rows (each 1/6 of tile).
* Upper: click zone → navigate into category.
* Lower: gray band — left chevron | delete | right chevron.
*/ */
function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) { function BottomRow({ onOpen, editBand }) {
return ( return (
<Box <Box
sx={{ sx={{
@@ -93,23 +142,67 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
bottom: 0, bottom: 0,
left: 0, left: 0,
right: 0, right: 0,
height: '16.66%', height: '33.33%',
display: 'flex', display: 'flex',
alignItems: 'center', flexDirection: 'column',
justifyContent: 'space-between',
px: 0.5,
bgcolor: 'grey.400',
}} }}
> >
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach links verschieben" disabled={!canLeft} onClick={onMoveLeft}> {/* Upper half: click-through to open category */}
<ChevronLeftIcon sx={{ fontSize: 18 }} /> <Box
</IconButton> role="button"
<IconButton className="sortable-handle-btn" size="small" aria-label="Löschen" onClick={onDelete}> tabIndex={0}
<DeleteOutlinedIcon sx={{ fontSize: 18 }} /> onClick={onOpen}
</IconButton> onKeyDown={(e) => {
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach rechts verschieben" disabled={!canRight} onClick={onMoveRight}> if (e.key === 'Enter' || e.key === ' ') {
<ChevronRightIcon sx={{ fontSize: 18 }} /> e.preventDefault();
</IconButton> onOpen?.();
}
}}
sx={{
flex: 1,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
}}
/>
{/* Lower half: reorder & delete */}
<Box
sx={{
height: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: 0.5,
bgcolor: 'grey.400',
}}
>
<IconButton
className="sortable-handle-btn"
size="small"
aria-label="Nach links verschieben"
disabled={!editBand.canLeft}
onClick={editBand.onMoveLeft}
>
<ChevronLeftIcon sx={{ fontSize: 18 }} />
</IconButton>
<IconButton
className="sortable-handle-btn"
size="small"
aria-label="Löschen"
onClick={editBand.onDelete}
>
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
</IconButton>
<IconButton
className="sortable-handle-btn"
size="small"
aria-label="Nach rechts verschieben"
disabled={!editBand.canRight}
onClick={editBand.onMoveRight}
>
<ChevronRightIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
</Box> </Box>
); );
} }
@@ -117,24 +210,105 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
/** /**
* Category tile (normal mode): no icon, click navigates to the category. * Category tile (normal mode): no icon, click navigates to the category.
*/ */
export class CategoryTile extends Tile { export class CategoryTile extends Component {
renderIcon() { handleClick = () => {
return null; this.props.onClick?.();
} };
renderLabel() { renderLabel() {
const { label, nameInput } = this.props; const { label, nameInput } = this.props;
return <NameBand label={label} nameInput={nameInput} />; if (label == null) return null;
return (
<Box
sx={{
position: 'absolute',
top: '33.33%',
bottom: '33.33%',
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'grey.300',
px: 1,
}}
>
{nameInput ?? (
<Typography
variant="body2"
fontWeight={600}
noWrap
sx={{ lineHeight: 1.2, color: 'text.primary' }}
>
{label}
</Typography>
)}
</Box>
);
}
render() {
const { sx, onClick: _onClick, label, ...rest } = this.props;
return (
<Box
component="button"
type="button"
onClick={this.handleClick}
{...rest}
sx={{
...tileBaseSx,
position: 'relative',
...sx,
}}
>
{this.renderLabel()}
</Box>
);
} }
} }
/** /**
* Category tile for edit mode: no ButtonBase (no ripple, no click navigation), * Category tile for edit mode: three rows of 1/3 each.
* always shows the bottom edit band with chevron reorder/delete actions. *
* Top row: sort-handle | settings toggle | drop-handle
* Middle row: gray band — TextField or POS toggle
* Bottom row: upper half = open-category click zone
* lower half = gray band with chevrons + delete
*/ */
export class CategoryEditTile extends Component { export class CategoryEditTile extends Component {
state = { settingsTab: -1 }; // -1 = rename, 0..n-1 = terminal index
handleSettingsToggle = () => {
const { terminals } = this.props;
const terminalCount = terminals?.length || 0;
this.setState((prev) => {
const next = prev.settingsTab + 1;
const nextTab = next > terminalCount ? -1 : next;
console.log('[CategoryEditTile] settings toggle', {
terminals: terminals?.length,
terminalCount,
from: prev.settingsTab,
to: nextTab,
});
return { settingsTab: nextTab };
});
};
render() { render() {
const { label, nameInput, editBand, sx, ...rest } = this.props; const {
label,
nameInput,
editBand,
onOpen,
terminals,
categoryPosterminalIds,
onTerminalToggle,
hasTerminals: _hasTerminals,
sx,
...rest
} = this.props;
const { settingsTab } = this.state;
return ( return (
<Box <Box
{...rest} {...rest}
@@ -146,15 +320,19 @@ export class CategoryEditTile extends Component {
...sx, ...sx,
}} }}
> >
<TopBand /> <TopBand
<NameBand label={label} nameInput={nameInput} /> onSettingsClick={this.handleSettingsToggle}
<EditBand settingsTab={settingsTab}
onMoveLeft={editBand.onMoveLeft} terminalCount={terminals?.length || 0}
onMoveRight={editBand.onMoveRight}
onDelete={editBand.onDelete}
canLeft={editBand.canLeft}
canRight={editBand.canRight}
/> />
<MiddleBand
settingsTab={settingsTab}
nameInput={nameInput}
terminals={terminals}
categoryPosterminalIds={categoryPosterminalIds}
onTerminalToggle={onTerminalToggle}
/>
<BottomRow onOpen={onOpen} editBand={editBand} />
</Box> </Box>
); );
} }

View File

@@ -21,6 +21,7 @@ import {
import withCategoryRoute from './withCategoryRoute'; import withCategoryRoute from './withCategoryRoute';
import { artikelDetailPath, articleSku } from './articleRoute'; import { artikelDetailPath, articleSku } from './articleRoute';
import { selectCompanyId } from '../../store/companySlice'; import { selectCompanyId } from '../../store/companySlice';
import { selectHasPosterminals, selectPosterminals } from '../../store/posterminalSlice';
import { import {
buildCategoryPath, buildCategoryPath,
defaultCategoryPath, defaultCategoryPath,
@@ -36,6 +37,7 @@ import {
navigateToCategory, navigateToCategory,
renameCategory, renameCategory,
reorderCategories, reorderCategories,
toggleCategoryPos,
selectCanNavigateBack, selectCanNavigateBack,
selectCategoryArticles, selectCategoryArticles,
selectCategoryArticlesStatus, selectCategoryArticlesStatus,
@@ -46,6 +48,10 @@ import {
selectChildCategories, selectChildCategories,
selectRenameMode, selectRenameMode,
} from '../../store/categorySlice'; } from '../../store/categorySlice';
import {
enableTerminalCategory,
disableTerminalCategory,
} from '../../store/posterminalSlice';
const gridSx = { const gridSx = {
display: 'grid', display: 'grid',
@@ -266,6 +272,8 @@ class ArtikelKategorienPage extends Component {
childCategories, childCategories,
articles, articles,
renameMode, renameMode,
hasTerminals,
terminals,
} = this.props; } = this.props;
if (!companyId) { if (!companyId) {
@@ -340,6 +348,19 @@ class ArtikelKategorienPage extends Component {
data-id={String(item.id)} data-id={String(item.id)}
className="sortable-item" className="sortable-item"
label={draftName} label={draftName}
terminals={terminals}
categoryPosterminalIds={item.posterminalIds || []}
onTerminalToggle={(terminalId, enabled) => {
const thunk = enabled
? this.props.enableTerminalCategory
: this.props.disableTerminalCategory;
void thunk({ terminalId, categoryId: item.id })
.unwrap()
.then(() => this.props.loadCategoryTree())
.catch(notifyError);
}}
onOpen={() => this.navigateToCategoryById(item.id)}
hasTerminals={hasTerminals}
nameInput={ nameInput={
<TextField <TextField
value={draftName} value={draftName}
@@ -396,7 +417,7 @@ class ArtikelKategorienPage extends Component {
/> />
))} ))}
<NewCategoryTile parentId={currentCategoryId} /> {renameMode && <NewCategoryTile parentId={currentCategoryId} />}
{!renameMode && ( {!renameMode && (
<NewArticleTile <NewArticleTile
parentId={currentCategoryId} parentId={currentCategoryId}
@@ -428,6 +449,8 @@ const mapStateToProps = (state) => ({
articles: selectCategoryArticles(state), articles: selectCategoryArticles(state),
canGoBack: selectCanNavigateBack(state), canGoBack: selectCanNavigateBack(state),
renameMode: selectRenameMode(state), renameMode: selectRenameMode(state),
hasTerminals: selectHasPosterminals(state),
terminals: selectPosterminals(state),
}); });
const mapDispatchToProps = { const mapDispatchToProps = {
@@ -438,6 +461,9 @@ const mapDispatchToProps = {
deleteCategory, deleteCategory,
moveCategory, moveCategory,
reorderCategories, reorderCategories,
toggleCategoryPos,
enableTerminalCategory,
disableTerminalCategory,
}; };
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage)); export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));

View File

@@ -15,6 +15,7 @@ export function normalizeCategoryNode(item) {
id: item.id, id: item.id,
parentId: item.parentId, parentId: item.parentId,
name: item.name == null ? null : String(item.name).trim() || null, name: item.name == null ? null : String(item.name).trim() || null,
posterminalIds: Array.isArray(item.posterminalIds) ? item.posterminalIds : [],
}; };
} }
@@ -219,6 +220,24 @@ export const deleteCategory = createAsyncThunk(
}, },
); );
/**
* Toggle the POS (Point of Sale) flag on a category.
* Backend: PATCH /api/category/:id Body: { pos: boolean } → { category }.
* PROPOSED — backend needs to implement this field.
*/
export const toggleCategoryPos = createAsyncThunk(
'category/togglePos',
async ({ categoryId, pos }, { rejectWithValue }) => {
try {
const data = await category.updatePos(categoryId, pos);
requireObject(data.category, 'PATCH /api/category/:id.category (pos)');
return normalizeCategoryNode(data.category);
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = { const initialState = {
companyId: null, companyId: null,
languageId: null, languageId: null,
@@ -403,6 +422,15 @@ const categorySlice = createSlice({
state.articlesStatus = 'succeeded'; state.articlesStatus = 'succeeded';
} }
}) })
.addCase(toggleCategoryPos.fulfilled, (state, action) => {
const updated = action.payload;
if (updated == null || state.categories == null) return;
const key = String(updated.id);
const existing = state.categories[key];
if (existing) {
state.categories[key] = { ...existing, pos: updated.pos };
}
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => { .addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null; const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) { if (nextCompanyId != null && nextCompanyId !== state.companyId) {

View File

@@ -3,12 +3,14 @@ import authReducer from './authSlice';
import brandReducer, { brandLoadListener } from './brandSlice'; import brandReducer, { brandLoadListener } from './brandSlice';
import categoryReducer from './categorySlice'; import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice'; import companyReducer, { companyLoadListener } from './companySlice';
import posterminalReducer, { posterminalLoadListener } from './posterminalSlice';
import unitReducer, { unitLoadListener } from './unitSlice'; import unitReducer, { unitLoadListener } from './unitSlice';
const listenerMiddleware = createListenerMiddleware(); const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware); companyLoadListener(listenerMiddleware);
unitLoadListener(listenerMiddleware); unitLoadListener(listenerMiddleware);
brandLoadListener(listenerMiddleware); brandLoadListener(listenerMiddleware);
posterminalLoadListener(listenerMiddleware);
function reduxDevLogger() { function reduxDevLogger() {
return (storeApi) => (next) => (action) => { return (storeApi) => (next) => (action) => {
@@ -18,6 +20,7 @@ function reduxDevLogger() {
!action.type?.startsWith('company/') && !action.type?.startsWith('company/') &&
!action.type?.startsWith('category/') && !action.type?.startsWith('category/') &&
!action.type?.startsWith('brand/') && !action.type?.startsWith('brand/') &&
!action.type?.startsWith('posterminal/') &&
!action.type?.startsWith('unit/')) !action.type?.startsWith('unit/'))
) { ) {
return next(action); return next(action);
@@ -34,6 +37,7 @@ function reduxDevLogger() {
}, },
company: after.company.current, company: after.company.current,
brands: after.brand.brands.length, brands: after.brand.brands.length,
posterminals: after.posterminal.terminals.length,
units: after.unit.units.length, units: after.unit.units.length,
}); });
return result; return result;
@@ -46,6 +50,7 @@ export const store = configureStore({
company: companyReducer, company: companyReducer,
category: categoryReducer, category: categoryReducer,
brand: brandReducer, brand: brandReducer,
posterminal: posterminalReducer,
unit: unitReducer, unit: unitReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>

View File

@@ -0,0 +1,271 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { posterminal } from '../api';
import { requireArray, requireDefined, requireObject } from '../utils/apiShape';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
import { loadCurrentCompany } from './companySlice';
/**
* Load all posterminals for the selected company.
* GET /api/posterminal → { posterminals: [...] }
*/
export const loadPosterminals = createAsyncThunk(
'posterminal/load',
async (_, { rejectWithValue, getState }) => {
try {
const companyId = getState().company.current?.id;
requireDefined(companyId, 'company.id');
const data = await posterminal.list();
requireObject(data, 'GET /api/posterminal');
const list = requireArray(data.posterminals, 'posterminals');
return { companyId, terminals: list };
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Create a new posterminal.
* PUT /api/posterminal → { posterminal: { id, name, token, pairingpin } }
*/
export const createPosterminal = createAsyncThunk(
'posterminal/create',
async ({ name }, { rejectWithValue }) => {
try {
const data = await posterminal.create({ name });
requireObject(data, 'PUT /api/posterminal');
requireObject(data.posterminal, 'posterminal');
requireDefined(data.posterminal.id, 'posterminal.id');
return data.posterminal;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Delete a posterminal.
* DELETE /api/posterminal/:id → 204
*/
export const deletePosterminal = createAsyncThunk(
'posterminal/delete',
async ({ terminalId }, { rejectWithValue }) => {
try {
await posterminal.delete(terminalId);
return terminalId;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable all categories on a terminal.
* PUT /api/posterminal/:id/all → { id, categoryIds }
*/
export const enableAllTerminalCategories = createAsyncThunk(
'posterminal/enableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableAllCategories(terminalId);
requireObject(data, 'PUT /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable all categories on a terminal.
* DELETE /api/posterminal/:id/all → { id, categoryIds }
*/
export const disableAllTerminalCategories = createAsyncThunk(
'posterminal/disableAll',
async ({ terminalId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableAllCategories(terminalId);
requireObject(data, 'DELETE /api/posterminal/:id/all');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Enable a specific category on a terminal.
* PUT /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const enableTerminalCategory = createAsyncThunk(
'posterminal/enableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.enableCategory(terminalId, categoryId);
requireObject(data, 'PUT /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
/**
* Disable a specific category on a terminal.
* DELETE /api/posterminal/:id/category/:categoryId → { id, categoryIds }
*/
export const disableTerminalCategory = createAsyncThunk(
'posterminal/disableCategory',
async ({ terminalId, categoryId }, { rejectWithValue }) => {
try {
const data = await posterminal.disableCategory(terminalId, categoryId);
requireObject(data, 'DELETE /api/posterminal/:id/category/:categoryId');
return data;
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
terminals: [],
status: 'idle',
error: null,
};
const posterminalSlice = createSlice({
name: 'posterminal',
initialState,
reducers: {
resetPosterminalState() {
return initialState;
},
},
extraReducers: (builder) => {
builder
// Load
.addCase(loadPosterminals.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadPosterminals.fulfilled, (state, action) => {
state.status = 'succeeded';
state.companyId = action.payload.companyId;
state.terminals = action.payload.terminals;
})
.addCase(loadPosterminals.rejected, (state, action) => {
state.status = 'failed';
state.error = resolveBackendError(action);
})
// Create
.addCase(createPosterminal.pending, (state) => {
state.error = null;
})
.addCase(createPosterminal.fulfilled, (state, action) => {
state.terminals.push(action.payload);
})
.addCase(createPosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Delete
.addCase(deletePosterminal.fulfilled, (state, action) => {
state.terminals = state.terminals.filter(
(t) => t.id !== action.payload,
);
})
.addCase(deletePosterminal.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable all categories
.addCase(enableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable all categories
.addCase(disableAllTerminalCategories.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableAllTerminalCategories.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Enable single category
.addCase(enableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(enableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Disable single category
.addCase(disableTerminalCategory.fulfilled, (state, action) => {
const { id, categoryIds } = action.payload;
const terminal = state.terminals.find((t) => t.id === id);
if (terminal) {
terminal.categoryIds = categoryIds;
}
})
.addCase(disableTerminalCategory.rejected, (state, action) => {
state.error = resolveBackendError(action);
})
// Reset on company change
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return { ...initialState, companyId: nextCompanyId, status: 'idle' };
}
})
// Reset on logout
.addCase(logout.fulfilled, () => initialState);
},
});
export const { resetPosterminalState } = posterminalSlice.actions;
/**
* Auto-load posterminals when the session/company is established.
* Mirrors brandLoadListener / unitLoadListener pattern.
*/
export function posterminalLoadListener(listenerMiddleware) {
listenerMiddleware.startListening({
matcher: (action) =>
bootstrapSession.fulfilled.match(action) ||
loginWithGoogle.fulfilled.match(action) ||
selectCompany.fulfilled.match(action) ||
createCompany.fulfilled.match(action),
effect: async (_action, api) => {
const companyId = api.getState().auth.user?.selectedCompanyId;
if (companyId) {
api.dispatch(loadPosterminals());
}
},
});
}
// Selectors
export const selectPosterminals = (state) => state.posterminal.terminals;
export const selectPosterminalStatus = (state) => state.posterminal.status;
export const selectPosterminalError = (state) => state.posterminal.error;
export const selectHasPosterminals = (state) => state.posterminal.terminals.length > 0;
export default posterminalSlice.reducer;