diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..4fe2531 --- /dev/null +++ b/TODO.md @@ -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`. diff --git a/src/api/index.js b/src/api/index.js index 251002f..37fdc8a 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -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 }; diff --git a/src/components/article/ArticleView.jsx b/src/components/article/ArticleView.jsx index 6ec634b..3f3cc79 100644 --- a/src/components/article/ArticleView.jsx +++ b/src/components/article/ArticleView.jsx @@ -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({ - - {brandName(articleData)} - + value={brandDraftValue(articleData)} + onSave={handleSaveBrand} + isDirty={brandDraftDirty} + canSave={brandDraftValid} + renderDisplay={(saved) => ( + {saved.name || null} + )} + renderEdit={(draft, onChange, inputRef) => ( + + typeof option === 'string' ? option : String(option.name ?? '') + } + onInputChange={(_event, value) => onChange({ name: value })} + onChange={(_event, option) => { + onChange({ name: option ? String(option.name ?? '') : '' }); + }} + renderInput={(params) => ( + + )} + /> + )} + /> - - {articleData.sku} - {eans.map((ean) => ( - - {ean} - - ))} - + contentAlignItems="flex-start" + value={eansDraftValue(articleData)} + onSave={handleSaveEans} + isDirty={eansDraftDirty} + canSave={eansDraftValid} + renderDisplay={(saved) => ( + <> + {articleData.sku} + {(saved.eans ?? []).map((ean) => ( + + {ean} + + ))} + + )} + renderEdit={(draft, onChange, inputRef) => ( + onChange({ eans })} + renderInput={(params) => ( + + )} + /> + )} + /> + 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; diff --git a/src/store/index.js b/src/store/index.js index 4c3aa65..c982709 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -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) =>