This commit is contained in:
seb
2026-07-31 08:34:51 +02:00
parent a95b7f4b1e
commit 797ea59bed
24 changed files with 1151 additions and 130 deletions

View File

@@ -31,11 +31,17 @@ export const logout = createAsyncThunk('auth/logout', async () => {
window.history.replaceState(null, '', '/');
});
function mergeUser(state, incoming) {
if (!incoming) return state.user;
const selectedCompanyId =
incoming.selectedCompanyId ?? state.user?.selectedCompanyId ?? null;
return { ...state.user, ...incoming, selectedCompanyId };
}
const initialState = {
user: null,
googleClientId: null,
status: 'loading',
companyReady: false,
};
const authSlice = createSlice({
@@ -46,40 +52,47 @@ const authSlice = createSlice({
builder
.addCase(bootstrapSession.fulfilled, (state, action) => {
const session = action.payload;
state.user = session.user ?? null;
state.googleClientId = session.googleClientId ?? null;
state.status = session.user ? 'authed' : 'anon';
state.companyReady = session.user?.selectedCompanyId != null;
state.googleClientId = session.googleClientId ?? state.googleClientId;
if (!session.user) {
if (state.status !== 'authed') {
state.user = null;
state.status = 'anon';
}
return;
}
state.user = mergeUser(state, session.user);
state.status = 'authed';
})
.addCase(bootstrapSession.rejected, (state) => {
state.user = null;
state.status = 'anon';
state.companyReady = false;
if (state.status === 'loading') {
state.user = null;
state.status = 'anon';
}
})
.addCase(loginWithGoogle.fulfilled, (state, action) => {
const session = action.payload;
state.user = session.user;
state.googleClientId = session.googleClientId ?? state.googleClientId;
state.status = 'authed';
state.companyReady = session.user?.selectedCompanyId != null;
})
.addCase(loginWithGoogle.rejected, (state) => {
state.user = null;
state.status = 'anon';
state.companyReady = false;
})
.addCase(selectCompany.fulfilled, (state, action) => {
state.user = { ...state.user, ...action.payload };
state.companyReady = true;
state.user = mergeUser(state, action.payload);
state.status = 'authed';
})
.addCase(createCompany.fulfilled, (state, action) => {
state.user = { ...state.user, ...action.payload };
state.companyReady = true;
state.user = mergeUser(state, action.payload);
state.status = 'authed';
})
.addCase(createCompany.rejected, (state, action) => {
console.error('[redux] createCompany.rejected', action.error?.message, action.payload);
})
.addCase(logout.fulfilled, (state) => {
state.user = null;
state.status = 'anon';
state.companyReady = false;
});
},
});
@@ -88,6 +101,6 @@ export const selectAuthStatus = (state) => state.auth.status;
export const selectAuthUser = (state) => state.auth.user;
export const selectGoogleClientId = (state) => state.auth.googleClientId;
export const selectNeedsCompanySetup = (state) =>
state.auth.status === 'authed' && !state.auth.companyReady;
state.auth.status === 'authed' && state.auth.user?.selectedCompanyId == null;
export default authSlice.reducer;

217
src/store/categorySlice.js Normal file
View File

@@ -0,0 +1,217 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { article, category } from '../api';
import { catchBackendError, resolveBackendError } from '../utils/backendError';
import { logout } from './authSlice';
import { loadCurrentCompany } from './companySlice';
export const ROOT_CATEGORY_NAME = 'Home';
export function normalizeCategoryNode(item) {
return {
...item,
id: item.id,
parentId: item.parentId ?? 0,
name: item.name?.trim() || null,
};
}
/** Backend: { tree: { tree: [...], articles: [] } } */
export function parseCategoryTreeResponse(data, companyRootCategoryId = null) {
const payload = data?.tree ?? data;
const rawNodes = payload?.tree ?? payload?.categories ?? data?.categories ?? [];
const list = Array.isArray(rawNodes) ? rawNodes : Object.values(rawNodes);
const categories = Object.fromEntries(
list.map((item) => [String(item.id), normalizeCategoryNode(item)]),
);
const rootCategoryId = data?.rootCategoryId ?? companyRootCategoryId ?? null;
const rootArticles = Array.isArray(payload?.articles) ? payload.articles : [];
return { rootCategoryId, categories, rootArticles };
}
export function categoryDisplayName(name, categoryId, rootCategoryId) {
if (rootCategoryId != null && String(categoryId) === String(rootCategoryId)) {
return ROOT_CATEGORY_NAME;
}
return name?.trim() || '';
}
export function normalizeCategoryMap(data) {
return parseCategoryTreeResponse(data).categories;
}
export function getCategoryChildren(categories, parentId) {
if (!categories) return [];
const parentKey = String(parentId);
return Object.values(categories)
.filter((item) => String(item.parentId ?? 0) === parentKey)
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.id - b.id);
}
export const loadCategoryTree = createAsyncThunk(
'category/loadTree',
async (companyId, { rejectWithValue, getState }) => {
try {
const data = await category.getTree({ companyId });
const fallbackRootId = getState().company.current?.rootCategoryId ?? null;
const { rootCategoryId, categories, rootArticles } = parseCategoryTreeResponse(
data,
fallbackRootId,
);
return {
companyId,
rootCategoryId,
categories,
rootArticles,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
export const loadCategoryArticles = createAsyncThunk(
'category/loadArticles',
async ({ categoryId, offset = 0, maxCount = 200 } = {}, { rejectWithValue }) => {
try {
const data = await article.byCategory({ categoryId, offset, maxCount });
return {
categoryId,
recordset: data.recordset ?? [],
length: data.length ?? data.recordset?.length ?? 0,
};
} catch (err) {
return catchBackendError(rejectWithValue)(err);
}
},
);
const initialState = {
companyId: null,
categories: null,
rootCategoryId: null,
rootArticles: [],
currentCategoryId: null,
articles: [],
articlesTotal: 0,
treeStatus: 'idle',
articlesStatus: 'idle',
error: null,
};
const categorySlice = createSlice({
name: 'category',
initialState,
reducers: {
navigateToCategory(state, action) {
state.currentCategoryId = action.payload;
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
state.error = null;
},
navigateBack(state) {
const current = state.categories?.[String(state.currentCategoryId)];
const parentId = current?.parentId;
if (parentId == null || parentId === 0) return;
if (String(parentId) === String(state.rootCategoryId) || state.categories?.[String(parentId)]) {
state.currentCategoryId = parentId;
state.error = null;
if (String(parentId) === String(state.rootCategoryId)) {
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
} else {
state.articles = [];
state.articlesTotal = 0;
state.articlesStatus = 'idle';
}
}
},
resetCategoryState() {
return initialState;
},
},
extraReducers: (builder) => {
builder
.addCase(loadCategoryTree.pending, (state) => {
state.treeStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryTree.fulfilled, (state, action) => {
state.treeStatus = 'succeeded';
state.companyId = action.payload.companyId;
state.categories = action.payload.categories;
state.rootCategoryId = action.payload.rootCategoryId;
state.currentCategoryId = action.payload.rootCategoryId;
state.rootArticles = action.payload.rootArticles ?? [];
state.articles = state.rootArticles;
state.articlesTotal = state.rootArticles.length;
state.articlesStatus = 'succeeded';
})
.addCase(loadCategoryTree.rejected, (state, action) => {
state.treeStatus = 'failed';
state.error = resolveBackendError(action);
})
.addCase(loadCategoryArticles.pending, (state) => {
state.articlesStatus = 'loading';
state.error = null;
})
.addCase(loadCategoryArticles.fulfilled, (state, action) => {
state.articlesStatus = 'succeeded';
state.articles = action.payload.recordset;
state.articlesTotal = action.payload.length;
})
.addCase(loadCategoryArticles.rejected, (state, action) => {
state.articlesStatus = 'failed';
state.articles = [];
state.articlesTotal = 0;
state.error = resolveBackendError(action);
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
const nextCompanyId = action.payload?.id ?? null;
if (nextCompanyId != null && nextCompanyId !== state.companyId) {
return {
...initialState,
companyId: nextCompanyId,
};
}
})
.addCase(logout.fulfilled, () => initialState);
},
});
export const {
navigateToCategory,
navigateBack,
resetCategoryState,
} = categorySlice.actions;
export const selectCategoryMap = (state) => state.category.categories;
export const selectCurrentCategoryId = (state) => state.category.currentCategoryId;
export const selectCategoryRootId = (state) => state.category.rootCategoryId;
export const selectCategoryTreeStatus = (state) => state.category.treeStatus;
export const selectCategoryArticles = (state) => state.category.articles;
export const selectCategoryArticlesStatus = (state) => state.category.articlesStatus;
export const selectCategoryError = (state) => state.category.error;
export const selectCurrentCategory = (state) => {
const id = state.category.currentCategoryId;
if (id == null) return null;
return state.category.categories?.[String(id)] ?? null;
};
export const selectChildCategories = (state) =>
getCategoryChildren(state.category.categories, state.category.currentCategoryId);
export const selectCanNavigateBack = (state) => {
const { currentCategoryId, rootCategoryId, categories } = state.category;
if (currentCategoryId == null || rootCategoryId == null) return false;
if (String(currentCategoryId) === String(rootCategoryId)) return false;
const parentId = categories?.[String(currentCategoryId)]?.parentId;
return parentId != null && parentId !== 0;
};
export default categorySlice.reducer;

103
src/store/companySlice.js Normal file
View File

@@ -0,0 +1,103 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import { company } from '../api';
import { normalizeCompany } from '../utils/companyForm';
import {
bootstrapSession,
createCompany,
loginWithGoogle,
logout,
selectCompany,
} from './authSlice';
export const loadCurrentCompany = createAsyncThunk(
'company/loadCurrent',
async (_, { getState }) => {
const companyId = getState().auth.user?.selectedCompanyId;
if (!companyId) return null;
const data = await company.getById(companyId);
return normalizeCompany(data.company);
},
);
export const updateCompany = createAsyncThunk(
'company/update',
async (payload, { getState }) => {
const companyId = getState().auth.user?.selectedCompanyId;
if (!companyId) throw new Error('Kein Unternehmen ausgewählt.');
await company.update(companyId, payload);
const data = await company.getById(companyId);
return normalizeCompany(data.company, payload);
},
);
const initialState = {
current: null,
status: 'idle',
error: null,
};
const companySlice = createSlice({
name: 'company',
initialState,
reducers: {},
extraReducers: (builder) => {
builder
.addCase(loadCurrentCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(loadCurrentCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(loadCurrentCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(updateCompany.pending, (state) => {
state.status = 'loading';
state.error = null;
})
.addCase(updateCompany.fulfilled, (state, action) => {
state.status = 'succeeded';
state.current = action.payload;
})
.addCase(updateCompany.rejected, (state, action) => {
state.status = 'failed';
state.error = action.error.message;
})
.addCase(logout.fulfilled, (state) => {
state.current = null;
state.status = 'idle';
state.error = null;
});
},
});
export const selectCurrentCompany = (state) => state.company.current;
export const selectCompanyId = (state) => state.company.current?.id ?? null;
export const selectCompanyStatus = (state) => state.company.status;
export const selectCompanyError = (state) => state.company.error;
export const selectCompanyName = (state) => state.company.current?.name ?? '';
export const selectCompanyIso639 = (state) => state.company.current?.iso639 ?? 'de';
export const selectCompanyTaxrate = (state) => state.company.current?.taxrate ?? null;
export const selectCompanyReducedTaxrate = (state) => state.company.current?.reducedtaxrate ?? null;
export const selectCompanyRootCategoryId = (state) => state.company.current?.rootCategoryId ?? null;
export function companyLoadListener(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(loadCurrentCompany());
}
},
});
}
export default companySlice.reducer;

View File

@@ -1,8 +1,45 @@
import { configureStore } from '@reduxjs/toolkit';
import { configureStore, createListenerMiddleware } from '@reduxjs/toolkit';
import authReducer from './authSlice';
import categoryReducer from './categorySlice';
import companyReducer, { companyLoadListener } from './companySlice';
const listenerMiddleware = createListenerMiddleware();
companyLoadListener(listenerMiddleware);
function reduxDevLogger() {
return (storeApi) => (next) => (action) => {
if (
!import.meta.env.DEV ||
(!action.type?.startsWith('auth/') &&
!action.type?.startsWith('company/') &&
!action.type?.startsWith('category/'))
) {
return next(action);
}
const before = storeApi.getState();
const result = next(action);
const after = storeApi.getState();
console.log('[redux]', action.type, {
payload: action.payload,
auth: {
selectedCompanyId: after.auth.user?.selectedCompanyId ?? null,
needsCompanySetup:
after.auth.status === 'authed' && after.auth.user?.selectedCompanyId == null,
},
company: after.company.current,
});
return result;
};
}
export const store = configureStore({
reducer: {
auth: authReducer,
company: companyReducer,
category: categoryReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware()
.concat(listenerMiddleware.middleware)
.concat(import.meta.env.DEV ? reduxDevLogger() : []),
});