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) =>