u
This commit is contained in:
53
TODO.md
Normal file
53
TODO.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# TODO — nächster Schritt (Brands / Artikelfelder)
|
||||
|
||||
**dashback** hat die HTTP-Routen geliefert (siehe `../dashback/TODO.md`). Alle Endpoints stehen. `companyId` kommt **immer aus der Auth-Session** (nicht aus `?companyId=`), gesteuert über `google_auth.setSelectedCompany`. Hier die UI bauen.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| UI-Bedarf | HTTP | Request | Response |
|
||||
| --- | --- | --- | --- |
|
||||
| Brand-Liste | `GET /api/brand` | – | `{ brands: Brand[] }` |
|
||||
| Brand anlegen / updaten | `PUT /api/brand` | `{ name, gsprpinfo? }` | `{ brand: Brand }` |
|
||||
| Brand löschen | `DELETE /api/brand/:id` | – | `204` |
|
||||
| EANs setzen (replace-all) | `PUT /api/article/:sku/eans` | `{ eans: string[] }` | `{ article }` (Detail) |
|
||||
| Brand setzen / entfernen | `PUT /api/article/:sku/brand` | `{ brandId: number \| null }` | `{ article }` (Detail) |
|
||||
| Gewicht setzen | `PUT /api/article/:sku/weight` | `{ weight: number }` | `{ article }` (Detail) |
|
||||
| Kategorien setzen (replace-all) | `PUT /api/article/:sku/categories` | `{ categoryIds: number[] }` | `{ article }` (Detail) |
|
||||
| Detail liefert neue Felder | `GET /api/article/:sku` | – | `{ article }` mit `eans`, `categoryIds`, `weight`, `brand` |
|
||||
|
||||
`Brand` = `{ id, name, gsprpinfo \| null }`.
|
||||
|
||||
Alle PUT/PATCH-Endpoints der Artikel liefern dasselbe Shape wie `GET /api/article/:sku`.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
### API-Client (`src/api/index.js`)
|
||||
- Alle Calls über den bestehenden Client (`credentials: 'include'`) — Frontend spricht nur HTTP, nie direkt den Store.
|
||||
- Neuen Methoden für: `brand.list` / `brand.upsert` / `brand.delete` und `article.setEansBySku` / `article.setBrandIdBySku` / `article.setWeightBySku` / `article.setCategoryIdsBySku`.
|
||||
- **Kein `companyId` in Request/Pfad/Query senden** — die Company kommt aus der Session.
|
||||
|
||||
### Brand-UI
|
||||
- **Liste**: lädt `GET /api/brand` und zeigt `name` (+ optional `gsprpinfo`).
|
||||
- **Anlegen/Updaten**: ein Formular (Name + optional GSPRP-Info). Upsert matcht per `name` — gleicher Name ⇒ Update, sonst Create. Leeres/fehlendes `name` ⇒ API `400 Invalid brand`.
|
||||
- **Löschen**: Bestätigung; `409 Brand in use` als Fehler anzeigen (Brand ist noch an Artikeln).
|
||||
- Hängt an `selectedCompanyId`: bei Company-Wechsel Liste neu laden / leeren.
|
||||
|
||||
### Artikel-Detail-Felder
|
||||
- **EANs**: Mehrfach-Eingabe (Liste). Replace-all: leere Liste löscht alle. `400 Invalid eans` bei leeren/duplizierten Einträgen.
|
||||
- **Brand**: Dropdown aus `GET /api/brand`; „Keine Brand“ ⇒ `brandId: null`. Deal nur mit Brands derselben Company (sonst `404 Brand not found`).
|
||||
- **Gewicht**: Zahlenfeld, `>= 0`. `400 Invalid weight` sonst.
|
||||
- **Kategorien**: Mehrfachauswahl aus der Category-Tree; nur Kategorien derselben Company (sonst `400 Invalid categoryIds`). Leere Liste entfernt alle.
|
||||
- Nach jedem Save das zurückgegebene Detail-Objekt in den Store übernehmen.
|
||||
|
||||
### Fehlerbehandlung
|
||||
- Fehler als Toast/Formular-Fehler abbilden:
|
||||
- `Brand in use` (409), `Invalid eans` / `Invalid weight` / `Invalid categoryIds` / `Invalid brand` (400)
|
||||
- `Brand not found` / `Article not found` (404)
|
||||
- `No company selected` (400) → Company-Wechsel-Flow
|
||||
- `Not authorized` (403)
|
||||
|
||||
## Referenz
|
||||
- API-Doku: `../dashback/docs/api/README.md` und `../dashback/docs/api/routes/`
|
||||
- `brand/list.md`, `brand/upsert.md`, `brand/delete.md`
|
||||
- `article/set-eans.md`, `article/set-brand.md`, `article/set-weight.md`, `article/set-categories.md`
|
||||
- Company-Scope: `google_auth.setSelectedCompany` (Store) — siehe `../dashdbschema/README.md`.
|
||||
@@ -235,6 +235,32 @@ export const article = {
|
||||
body: { prices },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/brand
|
||||
* Body: { brandId: number | null } — null clears the brand.
|
||||
* brandId must belong to the article's company → 404 Article or brand not found.
|
||||
* → 200 { article } (same shape as GET by SKU)
|
||||
*/
|
||||
setBrandIdBySku(sku, brandId) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/brand`, {
|
||||
method: 'PUT',
|
||||
body: { brandId },
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/article/:sku/eans
|
||||
* Body: { eans: string[] } — replace-all. Empty array clears all.
|
||||
* EANs must be non-empty unique strings → 400 Invalid eans.
|
||||
* → 200 { article } (same shape as GET by SKU)
|
||||
*/
|
||||
setEansBySku(sku, eans) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}/eans`, {
|
||||
method: 'PUT',
|
||||
body: { eans },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -308,6 +334,27 @@ export const category = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Company-scoped brands (selectedCompanyId from session).
|
||||
* Shape: Brand = { id, name, gsprpinfo | null }
|
||||
*/
|
||||
export const brand = {
|
||||
/** GET /api/brand → { brands: Brand[] } · 400 No company selected */
|
||||
list() {
|
||||
return http('/api/brand');
|
||||
},
|
||||
|
||||
/**
|
||||
* PUT /api/brand
|
||||
* Body: { name, gsprpinfo? } — upsert by name for the selected company.
|
||||
* Same name ⇒ update, otherwise create. Empty name ⇒ 400 Invalid brand.
|
||||
* → 200 { brand: Brand }
|
||||
*/
|
||||
upsert(body) {
|
||||
return http('/api/brand', { method: 'PUT', body });
|
||||
},
|
||||
};
|
||||
|
||||
export const company = {
|
||||
async list() {
|
||||
try {
|
||||
@@ -402,4 +449,4 @@ export const auth = {
|
||||
},
|
||||
};
|
||||
|
||||
export default { auth, article, category, company, thumb, unit };
|
||||
export default { auth, article, brand, category, company, thumb, unit };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Link as RouterLink, useNavigate } from 'react-router-dom';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Autocomplete from '@mui/material/Autocomplete';
|
||||
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
|
||||
import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined';
|
||||
import Box from '@mui/material/Box';
|
||||
@@ -23,10 +24,10 @@ import ArticleUnitEditorDialog from './ArticleUnitEditorDialog';
|
||||
import { article } from '../../api';
|
||||
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
|
||||
import { requireDefined, requireObject } from '../../utils/apiShape';
|
||||
import { loadBrands, selectBrands, selectBrandsStatus, upsertBrand } from '../../store/brandSlice';
|
||||
import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice';
|
||||
import {
|
||||
articleEans,
|
||||
brandName,
|
||||
buildPosPricesPutBody,
|
||||
companyTaxRateOptions,
|
||||
priceBrutto,
|
||||
@@ -123,6 +124,32 @@ function articleHasPackUnit(article) {
|
||||
return !isNoPackUnit(si, amount);
|
||||
}
|
||||
|
||||
function brandDraftValue(article) {
|
||||
return { name: article?.brand?.name ?? '' };
|
||||
}
|
||||
|
||||
function brandDraftDirty(draft, saved) {
|
||||
return String(draft.name ?? '').trim() !== String(saved.name ?? '').trim();
|
||||
}
|
||||
|
||||
function brandDraftValid(draft) {
|
||||
return String(draft.name ?? '').trim().length > 0;
|
||||
}
|
||||
|
||||
function eansDraftValue(article) {
|
||||
return { eans: articleEans(article) };
|
||||
}
|
||||
|
||||
function eansDraftDirty(draft, saved) {
|
||||
return JSON.stringify(draft.eans ?? []) !== JSON.stringify(saved.eans ?? []);
|
||||
}
|
||||
|
||||
function eansDraftValid(draft) {
|
||||
return (draft.eans ?? []).every(
|
||||
(ean) => typeof ean === 'string' && ean.trim().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function priceDraftValue(article, company) {
|
||||
const basePrice = primaryPrice(article);
|
||||
const gross = priceBrutto(basePrice);
|
||||
@@ -567,8 +594,9 @@ export default function ArticleView({
|
||||
const company = useSelector(selectCurrentCompany);
|
||||
const units = useSelector(selectUnits);
|
||||
const unitsStatus = useSelector(selectUnitsStatus);
|
||||
const brands = useSelector(selectBrands);
|
||||
const brandsStatus = useSelector(selectBrandsStatus);
|
||||
const basePrice = primaryPrice(articleData);
|
||||
const eans = articleEans(articleData);
|
||||
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
|
||||
const priceValue = priceDraftValue(articleData, company);
|
||||
const unitOptions =
|
||||
@@ -581,6 +609,11 @@ export default function ArticleView({
|
||||
void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err));
|
||||
}, [dispatch, unitsStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (brandsStatus !== 'idle') return;
|
||||
void dispatch(loadBrands()).unwrap().catch((err) => notifyError(err));
|
||||
}, [dispatch, brandsStatus]);
|
||||
|
||||
const syncCategoryListPhoto = (next) => {
|
||||
const photo = primaryPhoto(next);
|
||||
dispatch(
|
||||
@@ -618,6 +651,41 @@ export default function ArticleView({
|
||||
onArticleUpdated?.(res.article);
|
||||
};
|
||||
|
||||
const handleSaveBrand = async (draft) => {
|
||||
const name = String(draft.name ?? '').trim();
|
||||
if (!name) {
|
||||
throw new Error('Bitte eine Marke angeben.');
|
||||
}
|
||||
|
||||
const existing = brands.find(
|
||||
(row) => String(row.name ?? '').toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
|
||||
let brandId;
|
||||
if (existing) {
|
||||
brandId = existing.id;
|
||||
} else {
|
||||
const created = await dispatch(upsertBrand({ name })).unwrap();
|
||||
requireDefined(created.id, 'brand.id');
|
||||
brandId = created.id;
|
||||
}
|
||||
|
||||
const res = await article.setBrandIdBySku(articleData.sku, brandId);
|
||||
requireObject(res, 'PUT /api/article/:sku/brand');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
};
|
||||
|
||||
const handleSaveEans = async (draft) => {
|
||||
const eans = (draft.eans ?? [])
|
||||
.map((ean) => String(ean).trim())
|
||||
.filter((ean) => ean.length > 0);
|
||||
const res = await article.setEansBySku(articleData.sku, eans);
|
||||
requireObject(res, 'PUT /api/article/:sku/eans');
|
||||
requireObject(res.article, 'article');
|
||||
onArticleUpdated?.(res.article);
|
||||
};
|
||||
|
||||
const handleSavePrice = async (draft) => {
|
||||
const saved = priceDraftValue(articleData, company);
|
||||
let nextArticle = articleData;
|
||||
@@ -741,28 +809,85 @@ export default function ArticleView({
|
||||
</PanelValue>
|
||||
</PanelTile>
|
||||
|
||||
<PanelTile
|
||||
<EditablePanelTile
|
||||
wide={1}
|
||||
tileKey={PANEL_TILE_KEYS.brand}
|
||||
title="Marke"
|
||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||
>
|
||||
<PanelValue>{brandName(articleData)}</PanelValue>
|
||||
</PanelTile>
|
||||
value={brandDraftValue(articleData)}
|
||||
onSave={handleSaveBrand}
|
||||
isDirty={brandDraftDirty}
|
||||
canSave={brandDraftValid}
|
||||
renderDisplay={(saved) => (
|
||||
<PanelValue>{saved.name || null}</PanelValue>
|
||||
)}
|
||||
renderEdit={(draft, onChange, inputRef) => (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
freeSolo
|
||||
options={brands}
|
||||
value={draft.name ?? ''}
|
||||
inputValue={draft.name ?? ''}
|
||||
getOptionLabel={(option) =>
|
||||
typeof option === 'string' ? option : String(option.name ?? '')
|
||||
}
|
||||
onInputChange={(_event, value) => onChange({ name: value })}
|
||||
onChange={(_event, option) => {
|
||||
onChange({ name: option ? String(option.name ?? '') : '' });
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
inputRef={inputRef}
|
||||
label="Marke"
|
||||
size="small"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<PanelTile
|
||||
<EditablePanelTile
|
||||
wide={1}
|
||||
tileKey={PANEL_TILE_KEYS.identifiers}
|
||||
title="SKU / EAN"
|
||||
minHeight={SECOND_ROW_MIN_HEIGHT}
|
||||
>
|
||||
<PanelValue label="SKU">{articleData.sku}</PanelValue>
|
||||
{eans.map((ean) => (
|
||||
<PanelValue key={ean} label="EAN">
|
||||
{ean}
|
||||
</PanelValue>
|
||||
))}
|
||||
</PanelTile>
|
||||
contentAlignItems="flex-start"
|
||||
value={eansDraftValue(articleData)}
|
||||
onSave={handleSaveEans}
|
||||
isDirty={eansDraftDirty}
|
||||
canSave={eansDraftValid}
|
||||
renderDisplay={(saved) => (
|
||||
<>
|
||||
<PanelValue label="SKU">{articleData.sku}</PanelValue>
|
||||
{(saved.eans ?? []).map((ean) => (
|
||||
<PanelValue key={ean} label="EAN">
|
||||
{ean}
|
||||
</PanelValue>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
renderEdit={(draft, onChange, inputRef) => (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
multiple
|
||||
freeSolo
|
||||
options={[]}
|
||||
value={draft.eans ?? []}
|
||||
onChange={(_event, eans) => onChange({ eans })}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
inputRef={inputRef}
|
||||
label="EANs (Enter zum Hinzufügen)"
|
||||
size="small"
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<EditablePanelTile
|
||||
wide={1}
|
||||
|
||||
100
src/store/brandSlice.js
Normal file
100
src/store/brandSlice.js
Normal file
@@ -0,0 +1,100 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
|
||||
import { brand as brandApi } from '../api';
|
||||
import { requireArray, requireObject } from '../utils/apiShape';
|
||||
import { catchBackendError } from '../utils/backendError';
|
||||
import {
|
||||
bootstrapSession,
|
||||
createCompany,
|
||||
loginWithGoogle,
|
||||
logout,
|
||||
selectCompany,
|
||||
} from './authSlice';
|
||||
|
||||
function sortBrands(brands) {
|
||||
return [...brands].sort((a, b) =>
|
||||
String(a.name ?? '').localeCompare(String(b.name ?? ''), 'de'),
|
||||
);
|
||||
}
|
||||
|
||||
export const loadBrands = createAsyncThunk('brand/load', async (_, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await brandApi.list();
|
||||
requireObject(res, 'GET /api/brand');
|
||||
return requireArray(res.brands, 'brands');
|
||||
} catch (err) {
|
||||
return catchBackendError(rejectWithValue)(err);
|
||||
}
|
||||
});
|
||||
|
||||
export const upsertBrand = createAsyncThunk(
|
||||
'brand/upsert',
|
||||
async (body, { rejectWithValue }) => {
|
||||
try {
|
||||
const res = await brandApi.upsert(body);
|
||||
requireObject(res, 'PUT /api/brand');
|
||||
requireObject(res.brand, 'brand');
|
||||
return res.brand;
|
||||
} catch (err) {
|
||||
return catchBackendError(rejectWithValue)(err);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const initialState = {
|
||||
brands: [],
|
||||
status: 'idle',
|
||||
error: null,
|
||||
};
|
||||
|
||||
const brandSlice = createSlice({
|
||||
name: 'brand',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(loadBrands.pending, (state) => {
|
||||
state.status = 'loading';
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(loadBrands.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded';
|
||||
state.brands = sortBrands(action.payload);
|
||||
})
|
||||
.addCase(loadBrands.rejected, (state, action) => {
|
||||
state.status = 'failed';
|
||||
state.error = action.error.message;
|
||||
})
|
||||
.addCase(upsertBrand.fulfilled, (state, action) => {
|
||||
const row = action.payload;
|
||||
state.brands = sortBrands([
|
||||
...state.brands.filter((b) => b.id !== row.id),
|
||||
row,
|
||||
]);
|
||||
})
|
||||
.addCase(selectCompany.fulfilled, () => initialState)
|
||||
.addCase(createCompany.fulfilled, () => initialState)
|
||||
.addCase(logout.fulfilled, () => initialState);
|
||||
},
|
||||
});
|
||||
|
||||
export const selectBrands = (state) => state.brand.brands;
|
||||
export const selectBrandsStatus = (state) => state.brand.status;
|
||||
export const selectBrandsError = (state) => state.brand.error;
|
||||
|
||||
export function brandLoadListener(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(loadBrands());
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export default brandSlice.reducer;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
|
||||
import authReducer from './authSlice';
|
||||
import brandReducer, { brandLoadListener } from './brandSlice';
|
||||
import categoryReducer from './categorySlice';
|
||||
import companyReducer, { companyLoadListener } from './companySlice';
|
||||
import unitReducer, { unitLoadListener } from './unitSlice';
|
||||
@@ -7,6 +8,7 @@ import unitReducer, { unitLoadListener } from './unitSlice';
|
||||
const listenerMiddleware = createListenerMiddleware();
|
||||
companyLoadListener(listenerMiddleware);
|
||||
unitLoadListener(listenerMiddleware);
|
||||
brandLoadListener(listenerMiddleware);
|
||||
|
||||
function reduxDevLogger() {
|
||||
return (storeApi) => (next) => (action) => {
|
||||
@@ -15,6 +17,7 @@ function reduxDevLogger() {
|
||||
(!action.type?.startsWith('auth/') &&
|
||||
!action.type?.startsWith('company/') &&
|
||||
!action.type?.startsWith('category/') &&
|
||||
!action.type?.startsWith('brand/') &&
|
||||
!action.type?.startsWith('unit/'))
|
||||
) {
|
||||
return next(action);
|
||||
@@ -30,6 +33,7 @@ function reduxDevLogger() {
|
||||
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
|
||||
},
|
||||
company: after.company.current,
|
||||
brands: after.brand.brands.length,
|
||||
units: after.unit.units.length,
|
||||
});
|
||||
return result;
|
||||
@@ -41,6 +45,7 @@ export const store = configureStore({
|
||||
auth: authReducer,
|
||||
company: companyReducer,
|
||||
category: categoryReducer,
|
||||
brand: brandReducer,
|
||||
unit: unitReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
|
||||
Reference in New Issue
Block a user