u
This commit is contained in:
@@ -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
217
src/store/categorySlice.js
Normal 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
103
src/store/companySlice.js
Normal 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;
|
||||
@@ -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() : []),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user