feat(MemberOrders): implement member orders feature

- Added new models and routes for managing member orders and order history.
- Updated server.js to include member order routes and sync functionality.
- Enhanced frontend with new components and dialogs for viewing and managing orders.
- Integrated internationalization support for order-related texts across multiple languages.
- Updated navigation and views to include access to the new orders feature, improving user experience.
This commit is contained in:
Torsten Schulz (local)
2026-03-24 17:01:57 +01:00
parent e55ee0f88a
commit 02f1bed452
32 changed files with 1743 additions and 0 deletions

View File

@@ -0,0 +1,296 @@
import { Op } from 'sequelize';
import Member from '../models/Member.js';
import MemberOrder from '../models/MemberOrder.js';
import MemberOrderHistory from '../models/MemberOrderHistory.js';
import Club from '../models/Club.js';
import UserClub from '../models/UserClub.js';
import { checkAccess, getUserByToken } from '../utils/userUtils.js';
const ORDER_STATUSES = ['requested', 'ordered', 'arrived', 'handed_over'];
const normalizeAmount = (value) => {
const parsed = Number.parseFloat(value ?? 0);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Number(parsed.toFixed(2)));
};
const serializeOrder = (order) => {
const plain = typeof order.toJSON === 'function' ? order.toJSON() : { ...order };
const cost = normalizeAmount(plain.cost);
const paidAmount = normalizeAmount(plain.paidAmount);
return {
...plain,
cost,
paidAmount,
openAmount: Math.max(0, Number((cost - paidAmount).toFixed(2)))
};
};
const serializeHistoryEntry = (entry) => {
const plain = typeof entry.toJSON === 'function' ? entry.toJSON() : { ...entry };
const cost = normalizeAmount(plain.cost);
const paidAmount = normalizeAmount(plain.paidAmount);
return {
...plain,
cost,
paidAmount,
openAmount: Math.max(0, Number((cost - paidAmount).toFixed(2)))
};
};
class MemberOrderService {
async _ensureMemberAccess(userToken, clubId, memberId) {
await checkAccess(userToken, clubId);
const member = await Member.findOne({
where: {
id: memberId,
clubId
}
});
if (!member) {
throw new Error('Mitglied nicht gefunden');
}
return member;
}
async _createHistorySnapshot(order, transaction = null) {
return MemberOrderHistory.create({
memberOrderId: order.id,
memberId: order.memberId,
clubId: order.clubId,
item: order.item,
status: order.status,
changedAt: new Date(),
cost: normalizeAmount(order.cost),
paidAmount: normalizeAmount(order.paidAmount)
}, transaction ? { transaction } : undefined);
}
async getMemberOrders(userToken, clubId, memberId) {
await this._ensureMemberAccess(userToken, clubId, memberId);
const orders = await MemberOrder.findAll({
where: { clubId, memberId },
include: [
{
model: MemberOrderHistory,
as: 'historyEntries',
required: false
}
],
order: [
['createdAt', 'DESC'],
[{ model: MemberOrderHistory, as: 'historyEntries' }, 'changedAt', 'DESC']
]
});
return {
status: 200,
response: {
success: true,
orders: orders.map((order) => {
const serialized = serializeOrder(order);
serialized.historyEntries = (order.historyEntries || []).map(serializeHistoryEntry);
return serialized;
})
}
};
}
async createMemberOrder(userToken, clubId, memberId, payload) {
await this._ensureMemberAccess(userToken, clubId, memberId);
const item = String(payload?.item || '').trim();
const status = ORDER_STATUSES.includes(payload?.status) ? payload.status : 'requested';
const cost = normalizeAmount(payload?.cost);
const paidAmount = normalizeAmount(payload?.paidAmount);
if (!item) {
return {
status: 400,
response: { success: false, error: 'Artikel fehlt' }
};
}
const order = await MemberOrder.create({
memberId,
clubId,
item,
status,
orderDate: new Date(),
statusDate: new Date(),
cost,
paidAmount
});
await this._createHistorySnapshot(order);
const fresh = await MemberOrder.findByPk(order.id, {
include: [
{
model: MemberOrderHistory,
as: 'historyEntries',
required: false,
order: [['changedAt', 'DESC']]
}
]
});
const serialized = serializeOrder(fresh);
serialized.historyEntries = (fresh.historyEntries || []).map(serializeHistoryEntry);
return {
status: 200,
response: {
success: true,
order: serialized
}
};
}
async updateMemberOrder(userToken, clubId, memberId, orderId, payload) {
await this._ensureMemberAccess(userToken, clubId, memberId);
const order = await MemberOrder.findOne({
where: {
id: orderId,
clubId,
memberId
}
});
if (!order) {
return {
status: 404,
response: { success: false, error: 'Bestellung nicht gefunden' }
};
}
let changed = false;
const nextItem = payload?.item != null ? String(payload.item).trim() : order.item;
const nextStatus = payload?.status && ORDER_STATUSES.includes(payload.status) ? payload.status : order.status;
const nextCost = payload?.cost != null ? normalizeAmount(payload.cost) : normalizeAmount(order.cost);
const nextPaidAmount = payload?.paidAmount != null ? normalizeAmount(payload.paidAmount) : normalizeAmount(order.paidAmount);
if (nextItem && nextItem !== order.item) {
order.item = nextItem;
changed = true;
}
if (nextStatus !== order.status) {
order.status = nextStatus;
order.statusDate = new Date();
changed = true;
}
if (nextCost !== normalizeAmount(order.cost)) {
order.cost = nextCost;
changed = true;
}
if (nextPaidAmount !== normalizeAmount(order.paidAmount)) {
order.paidAmount = nextPaidAmount;
changed = true;
}
if (!changed) {
const existing = await this.getMemberOrders(userToken, clubId, memberId);
const current = existing.response.orders.find((entry) => String(entry.id) === String(orderId)) || null;
return {
status: 200,
response: {
success: true,
order: current
}
};
}
await order.save();
await this._createHistorySnapshot(order);
const updated = await MemberOrder.findByPk(order.id, {
include: [
{
model: MemberOrderHistory,
as: 'historyEntries',
required: false
}
],
order: [[{ model: MemberOrderHistory, as: 'historyEntries' }, 'changedAt', 'DESC']]
});
const serialized = serializeOrder(updated);
serialized.historyEntries = (updated.historyEntries || []).map(serializeHistoryEntry);
return {
status: 200,
response: {
success: true,
order: serialized
}
};
}
async getGlobalOrders(userToken) {
const user = await getUserByToken(userToken);
const userClubs = await UserClub.findAll({
where: {
userId: user.id,
approved: true
},
attributes: ['clubId']
});
const clubIds = userClubs.map((entry) => entry.clubId).filter(Boolean);
if (!clubIds.length) {
return {
status: 200,
response: {
success: true,
orders: []
}
};
}
const orders = await MemberOrder.findAll({
where: {
clubId: {
[Op.in]: clubIds
}
},
include: [
{
model: Member,
as: 'member',
attributes: ['id', 'firstName', 'lastName']
},
{
model: Club,
as: 'club',
attributes: ['id', 'name']
},
{
model: MemberOrderHistory,
as: 'historyEntries',
required: false
}
],
order: [
['statusDate', 'DESC'],
['createdAt', 'DESC'],
[{ model: MemberOrderHistory, as: 'historyEntries' }, 'changedAt', 'DESC']
]
});
return {
status: 200,
response: {
success: true,
orders: orders.map((order) => {
const serialized = serializeOrder(order);
serialized.historyEntries = (order.historyEntries || []).map(serializeHistoryEntry);
return serialized;
})
}
};
}
}
export default new MemberOrderService();