This commit is contained in:
seb
2026-08-02 18:54:25 +02:00
parent 780f946eff
commit b787549a85
5 changed files with 346 additions and 16 deletions

53
TODO.md Normal file
View 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`.

View File

@@ -235,6 +235,32 @@ export const article = {
body: { prices }, 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 = { export const company = {
async list() { async list() {
try { 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 };

View File

@@ -1,6 +1,7 @@
import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Autocomplete from '@mui/material/Autocomplete';
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'; import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined'; import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
@@ -23,10 +24,10 @@ import ArticleUnitEditorDialog from './ArticleUnitEditorDialog';
import { article } from '../../api'; import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels'; import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
import { requireDefined, requireObject } from '../../utils/apiShape'; import { requireDefined, requireObject } from '../../utils/apiShape';
import { loadBrands, selectBrands, selectBrandsStatus, upsertBrand } from '../../store/brandSlice';
import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice'; import { loadUnits, selectUnits, selectUnitsStatus } from '../../store/unitSlice';
import { import {
articleEans, articleEans,
brandName,
buildPosPricesPutBody, buildPosPricesPutBody,
companyTaxRateOptions, companyTaxRateOptions,
priceBrutto, priceBrutto,
@@ -123,6 +124,32 @@ function articleHasPackUnit(article) {
return !isNoPackUnit(si, amount); 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) { function priceDraftValue(article, company) {
const basePrice = primaryPrice(article); const basePrice = primaryPrice(article);
const gross = priceBrutto(basePrice); const gross = priceBrutto(basePrice);
@@ -567,8 +594,9 @@ export default function ArticleView({
const company = useSelector(selectCurrentCompany); const company = useSelector(selectCurrentCompany);
const units = useSelector(selectUnits); const units = useSelector(selectUnits);
const unitsStatus = useSelector(selectUnitsStatus); const unitsStatus = useSelector(selectUnitsStatus);
const brands = useSelector(selectBrands);
const brandsStatus = useSelector(selectBrandsStatus);
const basePrice = primaryPrice(articleData); const basePrice = primaryPrice(articleData);
const eans = articleEans(articleData);
const taxRates = companyTaxRateOptions(company, basePrice?.taxRate); const taxRates = companyTaxRateOptions(company, basePrice?.taxRate);
const priceValue = priceDraftValue(articleData, company); const priceValue = priceDraftValue(articleData, company);
const unitOptions = const unitOptions =
@@ -581,6 +609,11 @@ export default function ArticleView({
void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err)); void dispatch(loadUnits()).unwrap().catch((err) => notifyError(err));
}, [dispatch, unitsStatus]); }, [dispatch, unitsStatus]);
useEffect(() => {
if (brandsStatus !== 'idle') return;
void dispatch(loadBrands()).unwrap().catch((err) => notifyError(err));
}, [dispatch, brandsStatus]);
const syncCategoryListPhoto = (next) => { const syncCategoryListPhoto = (next) => {
const photo = primaryPhoto(next); const photo = primaryPhoto(next);
dispatch( dispatch(
@@ -618,6 +651,41 @@ export default function ArticleView({
onArticleUpdated?.(res.article); 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 handleSavePrice = async (draft) => {
const saved = priceDraftValue(articleData, company); const saved = priceDraftValue(articleData, company);
let nextArticle = articleData; let nextArticle = articleData;
@@ -741,28 +809,85 @@ export default function ArticleView({
</PanelValue> </PanelValue>
</PanelTile> </PanelTile>
<PanelTile <EditablePanelTile
wide={1} wide={1}
tileKey={PANEL_TILE_KEYS.brand} tileKey={PANEL_TILE_KEYS.brand}
title="Marke" title="Marke"
minHeight={SECOND_ROW_MIN_HEIGHT} minHeight={SECOND_ROW_MIN_HEIGHT}
> value={brandDraftValue(articleData)}
<PanelValue>{brandName(articleData)}</PanelValue> onSave={handleSaveBrand}
</PanelTile> 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} wide={1}
tileKey={PANEL_TILE_KEYS.identifiers} tileKey={PANEL_TILE_KEYS.identifiers}
title="SKU / EAN" title="SKU / EAN"
minHeight={SECOND_ROW_MIN_HEIGHT} minHeight={SECOND_ROW_MIN_HEIGHT}
> contentAlignItems="flex-start"
<PanelValue label="SKU">{articleData.sku}</PanelValue> value={eansDraftValue(articleData)}
{eans.map((ean) => ( onSave={handleSaveEans}
<PanelValue key={ean} label="EAN"> isDirty={eansDraftDirty}
{ean} canSave={eansDraftValid}
</PanelValue> renderDisplay={(saved) => (
))} <>
</PanelTile> <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 <EditablePanelTile
wide={1} wide={1}

100
src/store/brandSlice.js Normal file
View 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;

View File

@@ -1,5 +1,6 @@
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit'; import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import authReducer from './authSlice'; import authReducer from './authSlice';
import brandReducer, { brandLoadListener } from './brandSlice';
import categoryReducer from './categorySlice'; import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice'; import companyReducer, { companyLoadListener } from './companySlice';
import unitReducer, { unitLoadListener } from './unitSlice'; import unitReducer, { unitLoadListener } from './unitSlice';
@@ -7,6 +8,7 @@ import unitReducer, { unitLoadListener } from './unitSlice';
const listenerMiddleware = createListenerMiddleware(); const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware); companyLoadListener(listenerMiddleware);
unitLoadListener(listenerMiddleware); unitLoadListener(listenerMiddleware);
brandLoadListener(listenerMiddleware);
function reduxDevLogger() { function reduxDevLogger() {
return (storeApi) => (next) => (action) => { return (storeApi) => (next) => (action) => {
@@ -15,6 +17,7 @@ function reduxDevLogger() {
(!action.type?.startsWith('auth/') && (!action.type?.startsWith('auth/') &&
!action.type?.startsWith('company/') && !action.type?.startsWith('company/') &&
!action.type?.startsWith('category/') && !action.type?.startsWith('category/') &&
!action.type?.startsWith('brand/') &&
!action.type?.startsWith('unit/')) !action.type?.startsWith('unit/'))
) { ) {
return next(action); return next(action);
@@ -30,6 +33,7 @@ function reduxDevLogger() {
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null, after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
}, },
company: after.company.current, company: after.company.current,
brands: after.brand.brands.length,
units: after.unit.units.length, units: after.unit.units.length,
}); });
return result; return result;
@@ -41,6 +45,7 @@ export const store = configureStore({
auth: authReducer, auth: authReducer,
company: companyReducer, company: companyReducer,
category: categoryReducer, category: categoryReducer,
brand: brandReducer,
unit: unitReducer, unit: unitReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>