Genesis
This commit is contained in:
12
web/index.html
Normal file
12
web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>JTL-POS Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2650
web/package-lock.json
generated
Normal file
2650
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "jtl-pos-admin",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@mui/icons-material": "^6.4.6",
|
||||
"@mui/material": "^6.4.6",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^6.1.1"
|
||||
}
|
||||
}
|
||||
91
web/src/App.jsx
Normal file
91
web/src/App.jsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, NavLink } from 'react-router-dom';
|
||||
import {
|
||||
AppBar,
|
||||
Box,
|
||||
CssBaseline,
|
||||
Drawer,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Toolbar,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import DashboardIcon from '@mui/icons-material/Dashboard';
|
||||
import BluetoothSearchingIcon from '@mui/icons-material/BluetoothSearching';
|
||||
import InventoryIcon from '@mui/icons-material/Inventory';
|
||||
import CategoryIcon from '@mui/icons-material/Category';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import LoyaltyIcon from '@mui/icons-material/Loyalty';
|
||||
import DeleteSweepIcon from '@mui/icons-material/DeleteSweep';
|
||||
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import Pairing from './pages/Pairing';
|
||||
import Products from './pages/Products';
|
||||
import Categories from './pages/Categories';
|
||||
import Customers from './pages/Customers';
|
||||
import CustomerGroups from './pages/CustomerGroups';
|
||||
import Deleted from './pages/Deleted';
|
||||
|
||||
const drawerWidth = 240;
|
||||
|
||||
const navItems = [
|
||||
{ path: '/', label: 'Dashboard', icon: <DashboardIcon /> },
|
||||
{ path: '/pairing', label: 'Pairing', icon: <BluetoothSearchingIcon /> },
|
||||
{ path: '/products', label: 'Products', icon: <InventoryIcon /> },
|
||||
{ path: '/categories', label: 'Categories', icon: <CategoryIcon /> },
|
||||
{ path: '/customers', label: 'Customers', icon: <PeopleIcon /> },
|
||||
{ path: '/customer-groups', label: 'Customer Groups', icon: <LoyaltyIcon /> },
|
||||
{ path: '/deleted', label: 'Deleted', icon: <DeleteSweepIcon /> },
|
||||
];
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Box sx={{ display: 'flex' }}>
|
||||
<CssBaseline />
|
||||
<AppBar position="fixed" sx={{ zIndex: (theme) => theme.zIndex.drawer + 1 }}>
|
||||
<Toolbar>
|
||||
<Typography variant="h6" noWrap component="div">
|
||||
JTL-POS Admin
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
'& .MuiDrawer-paper': { width: drawerWidth, boxSizing: 'border-box' },
|
||||
}}
|
||||
>
|
||||
<Toolbar />
|
||||
<List>
|
||||
{navItems.map((item) => (
|
||||
<ListItem key={item.path} disablePadding>
|
||||
<ListItemButton component={NavLink} to={item.path}>
|
||||
<ListItemIcon>{item.icon}</ListItemIcon>
|
||||
<ListItemText primary={item.label} />
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Drawer>
|
||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||
<Toolbar />
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/pairing" element={<Pairing />} />
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/categories" element={<Categories />} />
|
||||
<Route path="/customers" element={<Customers />} />
|
||||
<Route path="/customer-groups" element={<CustomerGroups />} />
|
||||
<Route path="/deleted" element={<Deleted />} />
|
||||
</Routes>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
64
web/src/api.js
Normal file
64
web/src/api.js
Normal file
@@ -0,0 +1,64 @@
|
||||
const BASE = import.meta.env.VITE_ADMIN_API_BASE || '/admin/api';
|
||||
|
||||
async function request(method, path, body) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body !== undefined) {
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await fetch(`${BASE}${path}`, opts);
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${path} failed: ${res.status} ${text}`);
|
||||
}
|
||||
if (res.status === 204) {
|
||||
return null;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: (path) => request('GET', path),
|
||||
post: (path, body) => request('POST', path, body),
|
||||
patch: (path, body) => request('PATCH', path, body),
|
||||
put: (path, body) => request('PUT', path, body),
|
||||
delete: (path) => request('DELETE', path),
|
||||
};
|
||||
|
||||
export const init = () => api.get('/init');
|
||||
export const getState = () => api.get('/state');
|
||||
|
||||
export const listCategories = () => api.get('/categories');
|
||||
export const createCategory = (cat) => api.post('/categories', cat);
|
||||
export const updateCategory = (id, cat) => api.patch(`/categories/${id}`, cat);
|
||||
export const deleteCategory = (id) => api.delete(`/categories/${id}`);
|
||||
|
||||
export const listProducts = () => api.get('/products');
|
||||
export const createProduct = (prod) => api.post('/products', prod);
|
||||
export const updateProduct = (id, prod) => api.patch(`/products/${id}`, prod);
|
||||
export const deleteProduct = (id) => api.delete(`/products/${id}`);
|
||||
|
||||
export const listCustomers = () => api.get('/customers');
|
||||
export const createCustomer = (cust) => api.post('/customers', cust);
|
||||
export const updateCustomer = (id, cust) => api.patch(`/customers/${id}`, cust);
|
||||
export const deleteCustomer = (id) => api.delete(`/customers/${id}`);
|
||||
|
||||
export const listCustomerGroups = () => api.get('/customer-groups');
|
||||
export const createCustomerGroup = (g) => api.post('/customer-groups', g);
|
||||
export const updateCustomerGroup = (id, g) => api.patch(`/customer-groups/${id}`, g);
|
||||
export const deleteCustomerGroup = (id) => api.delete(`/customer-groups/${id}`);
|
||||
|
||||
export const listComposites = () => api.get('/product-composites');
|
||||
export const setComposite = (comp) => api.post('/product-composites', comp);
|
||||
export const removeComposite = (productId, componentId) =>
|
||||
api.delete(`/product-composites/${productId}/${componentId}`);
|
||||
|
||||
export const getPairing = () => api.get('/pairing');
|
||||
export const createPairingCode = (name) => api.post('/pairing', { name });
|
||||
export const revokePairingCode = (code) => api.post('/pairing/revoke', { code });
|
||||
export const revokeDevice = (token) => api.delete(`/devices/${token}`);
|
||||
|
||||
export const listDeleted = () => api.get('/deleted');
|
||||
export const recordDeleted = (entity) => api.post('/deleted', entity);
|
||||
150
web/src/components/CrudTable.jsx
Normal file
150
web/src/components/CrudTable.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
Paper,
|
||||
TextField,
|
||||
Button,
|
||||
IconButton,
|
||||
Stack,
|
||||
} from '@mui/material';
|
||||
import EditIcon from '@mui/icons-material/Edit';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SaveIcon from '@mui/icons-material/Save';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
|
||||
export default function CrudTable({
|
||||
columns,
|
||||
rows,
|
||||
idField,
|
||||
onSave,
|
||||
onDelete,
|
||||
emptyItem = {},
|
||||
title,
|
||||
}) {
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState({});
|
||||
|
||||
function startAdd() {
|
||||
setAdding(true);
|
||||
setForm({ ...emptyItem });
|
||||
}
|
||||
|
||||
function startEdit(row) {
|
||||
setEditing(row[idField]);
|
||||
setForm({ ...row });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setEditing(null);
|
||||
setAdding(false);
|
||||
setForm({});
|
||||
}
|
||||
|
||||
async function save() {
|
||||
await onSave(editing || adding ? form : null);
|
||||
cancel();
|
||||
}
|
||||
|
||||
function updateField(key, value) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
function isEditing(id) {
|
||||
return editing === id;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }} alignItems="center">
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={startAdd}>
|
||||
Add
|
||||
</Button>
|
||||
</Stack>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>{col.label}</TableCell>
|
||||
))}
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{adding && (
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={form[col.key] ?? ''}
|
||||
onChange={(e) => updateField(col.key, e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={save}>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={cancel}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map((row) =>
|
||||
isEditing(row[idField]) ? (
|
||||
<TableRow key={row[idField]}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={form[col.key] ?? ''}
|
||||
onChange={(e) => updateField(col.key, e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={save}>
|
||||
<SaveIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={cancel}>
|
||||
<CancelIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow key={row[idField]}>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>{row[col.key] ?? ''}</TableCell>
|
||||
))}
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" onClick={() => startEdit(row)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={() => onDelete(row[idField])}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
web/src/main.jsx
Normal file
23
web/src/main.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import App from './App';
|
||||
|
||||
const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
69
web/src/pages/Categories.jsx
Normal file
69
web/src/pages/Categories.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import CrudTable from '../components/CrudTable';
|
||||
import { listCategories, createCategory, updateCategory, deleteCategory } from '../api';
|
||||
|
||||
const columns = [
|
||||
{ key: '_id', label: 'ID' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'pid', label: 'Parent ID' },
|
||||
{ key: 'sort', label: 'Sort' },
|
||||
{ key: 'lastChanged', label: 'Last changed' },
|
||||
];
|
||||
|
||||
export default function Categories() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await listCategories();
|
||||
setRows(data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(form) {
|
||||
try {
|
||||
if (form._id && rows.some((r) => r._id === form._id)) {
|
||||
await updateCategory(form._id, form);
|
||||
} else {
|
||||
await createCategory(form);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await deleteCategory(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Categories
|
||||
</Typography>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
<CrudTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
idField="_id"
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
emptyItem={{ name: '', pid: '0', sort: '0' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
68
web/src/pages/CustomerGroups.jsx
Normal file
68
web/src/pages/CustomerGroups.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import CrudTable from '../components/CrudTable';
|
||||
import { listCustomerGroups, createCustomerGroup, updateCustomerGroup, deleteCustomerGroup } from '../api';
|
||||
|
||||
const columns = [
|
||||
{ key: 'customerGroupId', label: 'ID' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'standard', label: 'Standard' },
|
||||
{ key: 'discountPercent', label: 'Discount %' },
|
||||
];
|
||||
|
||||
export default function CustomerGroups() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await listCustomerGroups();
|
||||
setRows(data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(form) {
|
||||
try {
|
||||
if (form.customerGroupId && rows.some((r) => r.customerGroupId === form.customerGroupId)) {
|
||||
await updateCustomerGroup(form.customerGroupId, form);
|
||||
} else {
|
||||
await createCustomerGroup(form);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await deleteCustomerGroup(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Customer Groups
|
||||
</Typography>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
<CrudTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
idField="customerGroupId"
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
emptyItem={{ name: '', standard: '0', discountPercent: '0.00' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
71
web/src/pages/Customers.jsx
Normal file
71
web/src/pages/Customers.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Typography } from '@mui/material';
|
||||
import CrudTable from '../components/CrudTable';
|
||||
import { listCustomers, createCustomer, updateCustomer, deleteCustomer } from '../api';
|
||||
|
||||
const columns = [
|
||||
{ key: 'id', label: 'ID' },
|
||||
{ key: 'customerNumber', label: 'Number' },
|
||||
{ key: 'firstname', label: 'First name' },
|
||||
{ key: 'lastname', label: 'Last name' },
|
||||
{ key: 'company', label: 'Company' },
|
||||
{ key: 'city', label: 'City' },
|
||||
{ key: 'customerGroupId', label: 'Group ID' },
|
||||
];
|
||||
|
||||
export default function Customers() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await listCustomers();
|
||||
setRows(data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(form) {
|
||||
try {
|
||||
if (form.id && rows.some((r) => r.id === form.id)) {
|
||||
await updateCustomer(form.id, form);
|
||||
} else {
|
||||
await createCustomer(form);
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await deleteCustomer(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Customers
|
||||
</Typography>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
<CrudTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
idField="id"
|
||||
onSave={handleSave}
|
||||
onDelete={handleDelete}
|
||||
emptyItem={{ customerNumber: '0', firstname: '', lastname: '', company: '', city: '', customerGroupId: '1' }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
61
web/src/pages/Dashboard.jsx
Normal file
61
web/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Card, CardContent, Grid, Typography, Chip } from '@mui/material';
|
||||
import { init } from '../api';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
init().then(setData).catch((e) => setError(e.message));
|
||||
}, []);
|
||||
|
||||
const counts = data?.counts || {};
|
||||
const devices = data?.state?.pairedDevices
|
||||
? Object.values(data.state.pairedDevices)
|
||||
: [];
|
||||
|
||||
const countCards = [
|
||||
{ label: 'Products', value: counts.product_count },
|
||||
{ label: 'Categories', value: counts.category_count },
|
||||
{ label: 'Customers', value: counts.customer_count },
|
||||
{ label: 'Customer Groups', value: counts.customerGroup_count },
|
||||
{ label: 'Composites', value: counts.compositeProduct_count },
|
||||
{ label: 'Deleted', value: counts.deletedEntity_count },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Dashboard
|
||||
</Typography>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||
{countCards.map((c) => (
|
||||
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={c.label}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography color="textSecondary" gutterBottom>
|
||||
{c.label}
|
||||
</Typography>
|
||||
<Typography variant="h3">{c.value ?? '-'}</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Paired Devices
|
||||
</Typography>
|
||||
{devices.length === 0 ? (
|
||||
<Typography color="textSecondary">No devices paired yet.</Typography>
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{devices.map((d) => (
|
||||
<Chip key={d.token} label={d.name || d.token.slice(0, 8)} title={d.token} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
112
web/src/pages/Deleted.jsx
Normal file
112
web/src/pages/Deleted.jsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import { listDeleted, recordDeleted } from '../api';
|
||||
|
||||
const entityTypes = {
|
||||
1: 'Product',
|
||||
2: 'Category',
|
||||
3: 'Customer',
|
||||
6: 'Customer Group',
|
||||
};
|
||||
|
||||
export default function Deleted() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
const [entityId, setEntityId] = useState('');
|
||||
const [entityType, setEntityType] = useState('1');
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const data = await listDeleted();
|
||||
setRows(data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAdd(e) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await recordDeleted({ entityId, entityType });
|
||||
setEntityId('');
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Deleted Entities
|
||||
</Typography>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
|
||||
<Box component="form" onSubmit={handleAdd} sx={{ display: 'flex', gap: 2, mb: 3 }}>
|
||||
<TextField
|
||||
label="Entity ID"
|
||||
value={entityId}
|
||||
onChange={(e) => setEntityId(e.target.value)}
|
||||
size="small"
|
||||
required
|
||||
/>
|
||||
<TextField
|
||||
label="Entity type"
|
||||
value={entityType}
|
||||
onChange={(e) => setEntityType(e.target.value)}
|
||||
size="small"
|
||||
required
|
||||
helperText="1=Product,2=Category,3=Customer,6=Group"
|
||||
/>
|
||||
<Button variant="contained" type="submit" startIcon={<AddIcon />}>
|
||||
Record deletion
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Entity ID</TableCell>
|
||||
<TableCell>Entity Type</TableCell>
|
||||
<TableCell>Last Changed</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>No deleted entities</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{rows.map((row) => (
|
||||
<TableRow key={`${row.entityId}-${row.lastChanged}`}>
|
||||
<TableCell>{row.entityId}</TableCell>
|
||||
<TableCell>
|
||||
{row.entityType} {entityTypes[row.entityType] ? `(${entityTypes[row.entityType]})` : ''}
|
||||
</TableCell>
|
||||
<TableCell>{row.lastChanged}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
163
web/src/pages/Pairing.jsx
Normal file
163
web/src/pages/Pairing.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import {
|
||||
getPairing,
|
||||
createPairingCode,
|
||||
revokePairingCode,
|
||||
revokeDevice,
|
||||
} from '../api';
|
||||
|
||||
export default function Pairing() {
|
||||
const [data, setData] = useState({ authCodes: [], pairedDevices: [] });
|
||||
const [name, setName] = useState('JTL-POS');
|
||||
const [msg, setMsg] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const d = await getPairing();
|
||||
setData(d);
|
||||
setError('');
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCode() {
|
||||
try {
|
||||
const res = await createPairingCode(name || 'JTL-POS');
|
||||
setMsg(`New pairing code: ${res.code}`);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeCode(code) {
|
||||
try {
|
||||
await revokePairingCode(code);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeDev(token) {
|
||||
try {
|
||||
await revokeDevice(token);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Pairing
|
||||
</Typography>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
{msg && <Typography color="primary">{msg}</Typography>}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, mb: 3, alignItems: 'center' }}>
|
||||
<TextField
|
||||
label="Device name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
size="small"
|
||||
/>
|
||||
<Button variant="contained" onClick={generateCode}>
|
||||
Generate 6-digit code
|
||||
</Button>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={refresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Pending pairing codes
|
||||
</Typography>
|
||||
<TableContainer component={Paper} sx={{ mb: 3 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Code</TableCell>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.authCodes.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>No pending codes</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{data.authCodes.map((code) => (
|
||||
<TableRow key={code.code}>
|
||||
<TableCell>{code.code}</TableCell>
|
||||
<TableCell>{code.name}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={() => revokeCode(code.code)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Paired devices
|
||||
</Typography>
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Token</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.pairedDevices.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>No paired devices</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{data.pairedDevices.map((dev) => (
|
||||
<TableRow key={dev.token}>
|
||||
<TableCell>{dev.name}</TableCell>
|
||||
<TableCell>{dev.token.slice(0, 16)}…</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={() => revokeDev(dev.token)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
319
web/src/pages/Products.jsx
Normal file
319
web/src/pages/Products.jsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
IconButton,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import {
|
||||
listProducts,
|
||||
createProduct,
|
||||
updateProduct,
|
||||
deleteProduct,
|
||||
listComposites,
|
||||
setComposite,
|
||||
removeComposite,
|
||||
} from '../api';
|
||||
|
||||
const productColumns = [
|
||||
{ key: '_id', label: 'ID' },
|
||||
{ key: 'name', label: 'Name' },
|
||||
{ key: 'sku', label: 'SKU' },
|
||||
{ key: 'price', label: 'Price' },
|
||||
{ key: 'tax_rate', label: 'Tax %' },
|
||||
{ key: 'categories_id', label: 'Category ID' },
|
||||
{ key: 'isCompositeProduct', label: 'Composite' },
|
||||
];
|
||||
|
||||
export default function Products() {
|
||||
const [products, setProducts] = useState([]);
|
||||
const [composites, setComposites] = useState([]);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [editing, setEditing] = useState(null);
|
||||
const [form, setForm] = useState({});
|
||||
|
||||
const [compositeParent, setCompositeParent] = useState('');
|
||||
const [compositeComponent, setCompositeComponent] = useState('');
|
||||
const [compositeQuantity, setCompositeQuantity] = useState('1.00');
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const [p, c] = await Promise.all([listProducts(), listComposites()]);
|
||||
setProducts(p);
|
||||
setComposites(c);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
try {
|
||||
if (editing) {
|
||||
await updateProduct(form._id, form);
|
||||
} else {
|
||||
await createProduct(form);
|
||||
}
|
||||
setEditing(null);
|
||||
setForm({});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await deleteProduct(id);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function addComposite() {
|
||||
try {
|
||||
await setComposite({
|
||||
productId: compositeParent,
|
||||
productIdComponent: compositeComponent,
|
||||
quantity: compositeQuantity,
|
||||
});
|
||||
setCompositeComponent('');
|
||||
setCompositeQuantity('1.00');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteComposite(productId, componentId) {
|
||||
try {
|
||||
await removeComposite(productId, componentId);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
setEditing('new');
|
||||
setForm({ name: '', sku: '', price: '0.00', tax_rate: '19', categories_id: '1', isCompositeProduct: '0' });
|
||||
}
|
||||
|
||||
function startEdit(row) {
|
||||
setEditing(row._id);
|
||||
setForm({ ...row });
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
setEditing(null);
|
||||
setForm({});
|
||||
}
|
||||
|
||||
function updateField(key, value) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
const compositeProducts = products.filter((p) => p.isCompositeProduct === '1');
|
||||
const nonCompositeProducts = products.filter((p) => p.isCompositeProduct !== '1');
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h4" gutterBottom>
|
||||
Products
|
||||
</Typography>
|
||||
{error && <Typography color="error">{error}</Typography>}
|
||||
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={startAdd}>
|
||||
Add product
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<TableContainer component={Paper} sx={{ mb: 4 }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
{productColumns.map((col) => (
|
||||
<TableCell key={col.key}>{col.label}</TableCell>
|
||||
))}
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{editing === 'new' && (
|
||||
<EditableRow
|
||||
columns={productColumns}
|
||||
form={form}
|
||||
onChange={updateField}
|
||||
onSave={handleSave}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
)}
|
||||
{products.map((row) =>
|
||||
editing === row._id ? (
|
||||
<EditableRow
|
||||
key={row._id}
|
||||
columns={productColumns}
|
||||
form={form}
|
||||
onChange={updateField}
|
||||
onSave={handleSave}
|
||||
onCancel={cancel}
|
||||
/>
|
||||
) : (
|
||||
<TableRow key={row._id}>
|
||||
{productColumns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
{col.key === 'isCompositeProduct'
|
||||
? row[col.key] === '1'
|
||||
? 'Yes'
|
||||
: 'No'
|
||||
: row[col.key] ?? ''}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={() => startEdit(row)}>
|
||||
Edit
|
||||
</Button>
|
||||
<IconButton size="small" color="error" onClick={() => handleDelete(row._id)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
|
||||
<Typography variant="h5" gutterBottom>
|
||||
Composite components
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} sx={{ mb: 2 }} alignItems="center">
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Composite product</InputLabel>
|
||||
<Select
|
||||
value={compositeParent}
|
||||
label="Composite product"
|
||||
onChange={(e) => setCompositeParent(e.target.value)}
|
||||
>
|
||||
{compositeProducts.map((p) => (
|
||||
<MenuItem key={p._id} value={p._id}>
|
||||
{p.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl size="small" sx={{ minWidth: 160 }}>
|
||||
<InputLabel>Component</InputLabel>
|
||||
<Select
|
||||
value={compositeComponent}
|
||||
label="Component"
|
||||
onChange={(e) => setCompositeComponent(e.target.value)}
|
||||
>
|
||||
{nonCompositeProducts.map((p) => (
|
||||
<MenuItem key={p._id} value={p._id}>
|
||||
{p.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label="Quantity"
|
||||
value={compositeQuantity}
|
||||
onChange={(e) => setCompositeQuantity(e.target.value)}
|
||||
size="small"
|
||||
sx={{ width: 100 }}
|
||||
/>
|
||||
<Button variant="contained" onClick={addComposite} disabled={!compositeParent || !compositeComponent}>
|
||||
Add component
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Composite</TableCell>
|
||||
<TableCell>Component</TableCell>
|
||||
<TableCell>Quantity</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{composites.map((c) => (
|
||||
<TableRow key={`${c.productId}-${c.productIdComponent}`}>
|
||||
<TableCell>{products.find((p) => p._id === c.productId)?.name || c.productId}</TableCell>
|
||||
<TableCell>{products.find((p) => p._id === c.productIdComponent)?.name || c.productIdComponent}</TableCell>
|
||||
<TableCell>{c.quantity}</TableCell>
|
||||
<TableCell align="right">
|
||||
<IconButton size="small" color="error" onClick={() => deleteComposite(c.productId, c.productIdComponent)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EditableRow({ columns, form, onChange, onSave, onCancel }) {
|
||||
return (
|
||||
<TableRow>
|
||||
{columns.map((col) => (
|
||||
<TableCell key={col.key}>
|
||||
{col.key === 'isCompositeProduct' ? (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={String(form[col.key]) === '1'}
|
||||
onChange={(e) => onChange(col.key, e.target.checked ? '1' : '0')}
|
||||
/>
|
||||
}
|
||||
label="Composite"
|
||||
/>
|
||||
) : (
|
||||
<TextField
|
||||
size="small"
|
||||
value={form[col.key] ?? ''}
|
||||
onChange={(e) => onChange(col.key, e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={onSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="small" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
18
web/vite.config.js
Normal file
18
web/vite.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/admin/api': {
|
||||
target: 'http://localhost:8087',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user