u
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -4,3 +4,4 @@ dist-ssr
|
||||
*.local
|
||||
.DS_Store
|
||||
certs/
|
||||
.dashfront-token
|
||||
177
src/api/api-tester.mjs
Normal file
177
src/api/api-tester.mjs
Normal 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();
|
||||
@@ -371,6 +371,20 @@ export const category = {
|
||||
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 = {
|
||||
async me() {
|
||||
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 };
|
||||
|
||||
@@ -8,13 +8,22 @@ import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import FormLabel from '@mui/material/FormLabel';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
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 Switch from '@mui/material/Switch';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import ToggleButton from '@mui/material/ToggleButton';
|
||||
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 BackendError from './BackendError';
|
||||
import SingleLineTextEdit from './SingleLineTextEdit';
|
||||
@@ -25,8 +34,18 @@ import {
|
||||
selectCurrentCompany,
|
||||
updateCompany as updateCompanyThunk,
|
||||
} 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 { backendErrorFrom, isBackendFailure } from '../utils/backendError';
|
||||
import { notifyError } from '../utils/notify';
|
||||
|
||||
class CompanySettingsDialog extends Component {
|
||||
constructor(props) {
|
||||
@@ -38,9 +57,15 @@ class CompanySettingsDialog extends Component {
|
||||
iso639: 'de',
|
||||
taxrate: '',
|
||||
reducedtaxrate: '',
|
||||
newTerminalName: '',
|
||||
creatingTerminal: false,
|
||||
enableAllForNew: true,
|
||||
};
|
||||
this.handleSubmit = this.handleSubmit.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) {
|
||||
@@ -52,6 +77,10 @@ class CompanySettingsDialog extends Component {
|
||||
} else if (companyStatus !== 'loading') {
|
||||
void this.props.loadCurrentCompany();
|
||||
}
|
||||
// Load posterminals when dialog opens
|
||||
if (this.props.posterminalStatus !== 'loading') {
|
||||
void this.props.loadPosterminals();
|
||||
}
|
||||
}
|
||||
if (open && company && company !== prevProps.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) {
|
||||
event.preventDefault();
|
||||
const { onClose } = this.props;
|
||||
@@ -200,6 +279,77 @@ class CompanySettingsDialog extends Component {
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</DialogContent>
|
||||
@@ -221,11 +371,18 @@ const mapStateToProps = (state) => ({
|
||||
user: selectAuthUser(state),
|
||||
company: selectCurrentCompany(state),
|
||||
companyStatus: selectCompanyStatus(state),
|
||||
terminals: selectPosterminals(state),
|
||||
posterminalStatus: selectPosterminalStatus(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = (dispatch) => ({
|
||||
loadCurrentCompany: () => dispatch(loadCurrentCompany()),
|
||||
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);
|
||||
|
||||
@@ -2,18 +2,22 @@ import { Component } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import DragHandleIcon from '@mui/icons-material/DragHandle';
|
||||
import AccountTreeIcon from '@mui/icons-material/AccountTree';
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
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
|
||||
* handle (Shopify Draggable Droppable).
|
||||
* Top row (1/3): sort handle | settings toggle | move handle.
|
||||
*/
|
||||
function TopBand() {
|
||||
function TopBand({ onSettingsClick, settingsTab, terminalCount }) {
|
||||
// settingsTab >= 0 means we're on a terminal tab
|
||||
const isTerminalMode = settingsTab >= 0 && settingsTab < terminalCount;
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -36,6 +40,20 @@ function TopBand() {
|
||||
>
|
||||
<DragHandleIcon sx={{ fontSize: 36 }} />
|
||||
</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
|
||||
className="droppable-handle"
|
||||
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 }) {
|
||||
if (label == null) return null;
|
||||
function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds, onTerminalToggle }) {
|
||||
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 (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -68,24 +124,17 @@ function NameBand({ label, nameInput }) {
|
||||
px: 1,
|
||||
}}
|
||||
>
|
||||
{nameInput ?? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{ lineHeight: 1.2, color: 'text.primary' }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
)}
|
||||
{nameInput}
|
||||
</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 (
|
||||
<Box
|
||||
sx={{
|
||||
@@ -93,23 +142,67 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '16.66%',
|
||||
height: '33.33%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 0.5,
|
||||
bgcolor: 'grey.400',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach links verschieben" disabled={!canLeft} onClick={onMoveLeft}>
|
||||
<ChevronLeftIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Löschen" onClick={onDelete}>
|
||||
<DeleteOutlinedIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
<IconButton className="sortable-handle-btn" size="small" aria-label="Nach rechts verschieben" disabled={!canRight} onClick={onMoveRight}>
|
||||
<ChevronRightIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{/* Upper half: click-through to open category */}
|
||||
<Box
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onOpen}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -117,24 +210,105 @@ function EditBand({ onMoveLeft, onMoveRight, onDelete, canLeft, canRight }) {
|
||||
/**
|
||||
* Category tile (normal mode): no icon, click navigates to the category.
|
||||
*/
|
||||
export class CategoryTile extends Tile {
|
||||
renderIcon() {
|
||||
return null;
|
||||
}
|
||||
export class CategoryTile extends Component {
|
||||
handleClick = () => {
|
||||
this.props.onClick?.();
|
||||
};
|
||||
|
||||
renderLabel() {
|
||||
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),
|
||||
* always shows the bottom edit band with chevron reorder/delete actions.
|
||||
* Category tile for edit mode: three rows of 1/3 each.
|
||||
*
|
||||
* 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 {
|
||||
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() {
|
||||
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 (
|
||||
<Box
|
||||
{...rest}
|
||||
@@ -146,15 +320,19 @@ export class CategoryEditTile extends Component {
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<TopBand />
|
||||
<NameBand label={label} nameInput={nameInput} />
|
||||
<EditBand
|
||||
onMoveLeft={editBand.onMoveLeft}
|
||||
onMoveRight={editBand.onMoveRight}
|
||||
onDelete={editBand.onDelete}
|
||||
canLeft={editBand.canLeft}
|
||||
canRight={editBand.canRight}
|
||||
<TopBand
|
||||
onSettingsClick={this.handleSettingsToggle}
|
||||
settingsTab={settingsTab}
|
||||
terminalCount={terminals?.length || 0}
|
||||
/>
|
||||
<MiddleBand
|
||||
settingsTab={settingsTab}
|
||||
nameInput={nameInput}
|
||||
terminals={terminals}
|
||||
categoryPosterminalIds={categoryPosterminalIds}
|
||||
onTerminalToggle={onTerminalToggle}
|
||||
/>
|
||||
<BottomRow onOpen={onOpen} editBand={editBand} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import withCategoryRoute from './withCategoryRoute';
|
||||
import { artikelDetailPath, articleSku } from './articleRoute';
|
||||
import { selectCompanyId } from '../../store/companySlice';
|
||||
import { selectHasPosterminals, selectPosterminals } from '../../store/posterminalSlice';
|
||||
import {
|
||||
buildCategoryPath,
|
||||
defaultCategoryPath,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
navigateToCategory,
|
||||
renameCategory,
|
||||
reorderCategories,
|
||||
toggleCategoryPos,
|
||||
selectCanNavigateBack,
|
||||
selectCategoryArticles,
|
||||
selectCategoryArticlesStatus,
|
||||
@@ -46,6 +48,10 @@ import {
|
||||
selectChildCategories,
|
||||
selectRenameMode,
|
||||
} from '../../store/categorySlice';
|
||||
import {
|
||||
enableTerminalCategory,
|
||||
disableTerminalCategory,
|
||||
} from '../../store/posterminalSlice';
|
||||
|
||||
const gridSx = {
|
||||
display: 'grid',
|
||||
@@ -266,6 +272,8 @@ class ArtikelKategorienPage extends Component {
|
||||
childCategories,
|
||||
articles,
|
||||
renameMode,
|
||||
hasTerminals,
|
||||
terminals,
|
||||
} = this.props;
|
||||
|
||||
if (!companyId) {
|
||||
@@ -340,6 +348,19 @@ class ArtikelKategorienPage extends Component {
|
||||
data-id={String(item.id)}
|
||||
className="sortable-item"
|
||||
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={
|
||||
<TextField
|
||||
value={draftName}
|
||||
@@ -396,7 +417,7 @@ class ArtikelKategorienPage extends Component {
|
||||
/>
|
||||
))}
|
||||
|
||||
<NewCategoryTile parentId={currentCategoryId} />
|
||||
{renameMode && <NewCategoryTile parentId={currentCategoryId} />}
|
||||
{!renameMode && (
|
||||
<NewArticleTile
|
||||
parentId={currentCategoryId}
|
||||
@@ -428,6 +449,8 @@ const mapStateToProps = (state) => ({
|
||||
articles: selectCategoryArticles(state),
|
||||
canGoBack: selectCanNavigateBack(state),
|
||||
renameMode: selectRenameMode(state),
|
||||
hasTerminals: selectHasPosterminals(state),
|
||||
terminals: selectPosterminals(state),
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
@@ -438,6 +461,9 @@ const mapDispatchToProps = {
|
||||
deleteCategory,
|
||||
moveCategory,
|
||||
reorderCategories,
|
||||
toggleCategoryPos,
|
||||
enableTerminalCategory,
|
||||
disableTerminalCategory,
|
||||
};
|
||||
|
||||
export default withCategoryRoute(connect(mapStateToProps, mapDispatchToProps)(ArtikelKategorienPage));
|
||||
|
||||
@@ -15,6 +15,7 @@ export function normalizeCategoryNode(item) {
|
||||
id: item.id,
|
||||
parentId: item.parentId,
|
||||
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 = {
|
||||
companyId: null,
|
||||
languageId: null,
|
||||
@@ -403,6 +422,15 @@ const categorySlice = createSlice({
|
||||
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) => {
|
||||
const nextCompanyId = action.payload?.id ?? null;
|
||||
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
|
||||
|
||||
@@ -3,12 +3,14 @@ import authReducer from './authSlice';
|
||||
import brandReducer, { brandLoadListener } from './brandSlice';
|
||||
import categoryReducer from './categorySlice';
|
||||
import companyReducer, { companyLoadListener } from './companySlice';
|
||||
import posterminalReducer, { posterminalLoadListener } from './posterminalSlice';
|
||||
import unitReducer, { unitLoadListener } from './unitSlice';
|
||||
|
||||
const listenerMiddleware = createListenerMiddleware();
|
||||
companyLoadListener(listenerMiddleware);
|
||||
unitLoadListener(listenerMiddleware);
|
||||
brandLoadListener(listenerMiddleware);
|
||||
posterminalLoadListener(listenerMiddleware);
|
||||
|
||||
function reduxDevLogger() {
|
||||
return (storeApi) => (next) => (action) => {
|
||||
@@ -18,6 +20,7 @@ function reduxDevLogger() {
|
||||
!action.type?.startsWith('company/') &&
|
||||
!action.type?.startsWith('category/') &&
|
||||
!action.type?.startsWith('brand/') &&
|
||||
!action.type?.startsWith('posterminal/') &&
|
||||
!action.type?.startsWith('unit/'))
|
||||
) {
|
||||
return next(action);
|
||||
@@ -34,6 +37,7 @@ function reduxDevLogger() {
|
||||
},
|
||||
company: after.company.current,
|
||||
brands: after.brand.brands.length,
|
||||
posterminals: after.posterminal.terminals.length,
|
||||
units: after.unit.units.length,
|
||||
});
|
||||
return result;
|
||||
@@ -46,6 +50,7 @@ export const store = configureStore({
|
||||
company: companyReducer,
|
||||
category: categoryReducer,
|
||||
brand: brandReducer,
|
||||
posterminal: posterminalReducer,
|
||||
unit: unitReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
|
||||
271
src/store/posterminalSlice.js
Normal file
271
src/store/posterminalSlice.js
Normal 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;
|
||||
Reference in New Issue
Block a user