From f302eb9e67179984472dcd89e6821ea010060dd7 Mon Sep 17 00:00:00 2001 From: seb Date: Mon, 3 Aug 2026 07:13:42 +0200 Subject: [PATCH] u --- .gitignore | 1 + src/api/api-tester.mjs | 177 +++++++++++++++ src/api/index.js | 93 +++++++- src/components/CompanySettingsDialog.jsx | 157 +++++++++++++ src/components/tiles/CategoryTile.jsx | 276 +++++++++++++++++++---- src/pages/artikel/kategorien.jsx | 28 ++- src/store/categorySlice.js | 28 +++ src/store/index.js | 5 + src/store/posterminalSlice.js | 271 ++++++++++++++++++++++ 9 files changed, 985 insertions(+), 51 deletions(-) create mode 100644 src/api/api-tester.mjs create mode 100644 src/store/posterminalSlice.js diff --git a/.gitignore b/.gitignore index 49788be..e0f042e 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist-ssr *.local .DS_Store certs/ +.dashfront-token \ No newline at end of file diff --git a/src/api/api-tester.mjs b/src/api/api-tester.mjs new file mode 100644 index 0000000..c0c52d7 --- /dev/null +++ b/src/api/api-tester.mjs @@ -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 [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 ) + * 3. Or pass --token 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 '); + console.error(' 3. Pass --token-file '); + 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(); diff --git a/src/api/index.js b/src/api/index.js index dac4a7d..26bb05e 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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 }; diff --git a/src/components/CompanySettingsDialog.jsx b/src/components/CompanySettingsDialog.jsx index 93d4151..178a977 100644 --- a/src/components/CompanySettingsDialog.jsx +++ b/src/components/CompanySettingsDialog.jsx @@ -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 { }} /> + + + + + {this.props.posterminalStatus === 'loading' ? ( + + + + ) : ( + <> + + {this.props.terminals.map((terminal) => ( + + + + + this.handleDeleteTerminal(terminal)} + disabled={busy || this.state.creatingTerminal} + > + + + + + ))} + + + + this.setState({ newTerminalName: e.target.value })} + disabled={busy || this.state.creatingTerminal} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.handleCreateTerminal(); + } + }} + sx={{ flex: 1 }} + /> + + + this.setState({ enableAllForNew: e.target.checked })} + disabled={busy || this.state.creatingTerminal} + /> + + + + + )} + )} @@ -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); diff --git a/src/components/tiles/CategoryTile.jsx b/src/components/tiles/CategoryTile.jsx index 31e4ce7..57ab7dd 100644 --- a/src/components/tiles/CategoryTile.jsx +++ b/src/components/tiles/CategoryTile.jsx @@ -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 ( + + {isTerminalMode ? ( + + ) : ( + + )} + = 0 && settingsTab < terminals.length) { + const terminal = terminals[settingsTab]; + const isEnabled = categoryPosterminalIds.includes(terminal.id); + return ( + + + + + {terminal.name} + + onTerminalToggle?.(terminal.id, e.target.checked)} + size="small" + /> + + + ); + } + return ( - {nameInput ?? ( - - {label} - - )} + {nameInput} ); } /** - * 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 ( - - - - - - - - - + {/* Upper half: click-through to open category */} + { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpen?.(); + } + }} + sx={{ + flex: 1, + cursor: 'pointer', + '&:hover': { bgcolor: 'action.hover' }, + }} + /> + + {/* Lower half: reorder & delete */} + + + + + + + + + + + ); } @@ -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 ; + if (label == null) return null; + return ( + + {nameInput ?? ( + + {label} + + )} + + ); + } + + render() { + const { sx, onClick: _onClick, label, ...rest } = this.props; + return ( + + {this.renderLabel()} + + ); } } /** - * 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 ( - - - + + ); } diff --git a/src/pages/artikel/kategorien.jsx b/src/pages/artikel/kategorien.jsx index e2ba48a..9731fed 100644 --- a/src/pages/artikel/kategorien.jsx +++ b/src/pages/artikel/kategorien.jsx @@ -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={ ))} - + {renameMode && } {!renameMode && ( ({ 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)); diff --git a/src/store/categorySlice.js b/src/store/categorySlice.js index 558a78c..7dd97ea 100644 --- a/src/store/categorySlice.js +++ b/src/store/categorySlice.js @@ -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) { diff --git a/src/store/index.js b/src/store/index.js index c982709..3b358cf 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -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) => diff --git a/src/store/posterminalSlice.js b/src/store/posterminalSlice.js new file mode 100644 index 0000000..7dc5bda --- /dev/null +++ b/src/store/posterminalSlice.js @@ -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;