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,55 @@
import memberOrderService from '../services/memberOrderService.js';
const getMemberOrders = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await memberOrderService.getMemberOrders(userToken, clubId, memberId);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getMemberOrders] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellungen konnten nicht geladen werden.' });
}
};
const createMemberOrder = async (req, res) => {
try {
const { clubId, memberId } = req.params;
const { authcode: userToken } = req.headers;
const result = await memberOrderService.createMemberOrder(userToken, clubId, memberId, req.body || {});
res.status(result.status).json(result.response);
} catch (error) {
console.error('[createMemberOrder] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellung konnte nicht gespeichert werden.' });
}
};
const updateMemberOrder = async (req, res) => {
try {
const { clubId, memberId, orderId } = req.params;
const { authcode: userToken } = req.headers;
const result = await memberOrderService.updateMemberOrder(userToken, clubId, memberId, orderId, req.body || {});
res.status(result.status).json(result.response);
} catch (error) {
console.error('[updateMemberOrder] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellung konnte nicht aktualisiert werden.' });
}
};
const getGlobalOrders = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const result = await memberOrderService.getGlobalOrders(userToken);
res.status(result.status).json(result.response);
} catch (error) {
console.error('[getGlobalOrders] - Error:', error);
res.status(500).json({ success: false, error: 'Bestellübersicht konnte nicht geladen werden.' });
}
};
export {
getMemberOrders,
createMemberOrder,
updateMemberOrder,
getGlobalOrders
};

View File

@@ -0,0 +1,36 @@
CREATE TABLE IF NOT EXISTS member_orders (
id INT NOT NULL AUTO_INCREMENT,
member_id INT NOT NULL,
club_id INT NOT NULL,
item VARCHAR(255) NOT NULL,
status ENUM('requested', 'ordered', 'arrived', 'handed_over') NOT NULL DEFAULT 'requested',
order_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
status_date DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
cost DECIMAL(10,2) NOT NULL DEFAULT 0.00,
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_member_orders_member_id (member_id),
KEY idx_member_orders_club_id (club_id),
KEY idx_member_orders_status (status)
);
CREATE TABLE IF NOT EXISTS member_order_history (
id INT NOT NULL AUTO_INCREMENT,
member_order_id INT NOT NULL,
member_id INT NOT NULL,
club_id INT NOT NULL,
item VARCHAR(255) NOT NULL,
status ENUM('requested', 'ordered', 'arrived', 'handed_over') NOT NULL,
changed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
cost DECIMAL(10,2) NOT NULL DEFAULT 0.00,
paid_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_member_order_history_order_id (member_order_id),
KEY idx_member_order_history_member_id (member_id),
KEY idx_member_order_history_club_id (club_id),
KEY idx_member_order_history_changed_at (changed_at)
);

View File

@@ -0,0 +1,64 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberOrder = sequelize.define('MemberOrder', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_id'
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
item: {
type: DataTypes.STRING,
allowNull: false
},
status: {
type: DataTypes.ENUM('requested', 'ordered', 'arrived', 'handed_over'),
allowNull: false,
defaultValue: 'requested'
},
orderDate: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'order_date'
},
statusDate: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'status_date'
},
cost: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0
},
paidAmount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
field: 'paid_amount'
}
}, {
underscored: true,
tableName: 'member_orders',
timestamps: true,
indexes: [
{ fields: ['member_id'] },
{ fields: ['club_id'] },
{ fields: ['status'] }
]
});
export default MemberOrder;

View File

@@ -0,0 +1,63 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
const MemberOrderHistory = sequelize.define('MemberOrderHistory', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
allowNull: false
},
memberOrderId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_order_id'
},
memberId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'member_id'
},
clubId: {
type: DataTypes.INTEGER,
allowNull: false,
field: 'club_id'
},
item: {
type: DataTypes.STRING,
allowNull: false
},
status: {
type: DataTypes.ENUM('requested', 'ordered', 'arrived', 'handed_over'),
allowNull: false
},
changedAt: {
type: DataTypes.DATE,
allowNull: false,
defaultValue: DataTypes.NOW,
field: 'changed_at'
},
cost: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0
},
paidAmount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false,
defaultValue: 0,
field: 'paid_amount'
}
}, {
underscored: true,
tableName: 'member_order_history',
timestamps: true,
indexes: [
{ fields: ['member_order_id'] },
{ fields: ['member_id'] },
{ fields: ['club_id'] },
{ fields: ['changed_at'] }
]
});
export default MemberOrderHistory;

View File

@@ -49,6 +49,8 @@ import MemberTransferConfig from './MemberTransferConfig.js';
import MemberContact from './MemberContact.js';
import MemberImage from './MemberImage.js';
import MemberTtrHistory from './MemberTtrHistory.js';
import MemberOrder from './MemberOrder.js';
import MemberOrderHistory from './MemberOrderHistory.js';
import TrainingGroup from './TrainingGroup.js';
import MemberTrainingGroup from './MemberTrainingGroup.js';
import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js';
@@ -94,6 +96,13 @@ MemberNote.belongsTo(Member, { foreignKey: 'memberId' });
Member.hasMany(MemberTtrHistory, { as: 'ttrHistoryEntries', foreignKey: 'memberId' });
MemberTtrHistory.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Member.hasMany(MemberOrder, { as: 'orders', foreignKey: 'memberId' });
MemberOrder.belongsTo(Member, { as: 'member', foreignKey: 'memberId' });
Club.hasMany(MemberOrder, { as: 'memberOrders', foreignKey: 'clubId' });
MemberOrder.belongsTo(Club, { as: 'club', foreignKey: 'clubId' });
MemberOrder.hasMany(MemberOrderHistory, { as: 'historyEntries', foreignKey: 'memberOrderId' });
MemberOrderHistory.belongsTo(MemberOrder, { as: 'order', foreignKey: 'memberOrderId' });
DiaryDate.hasMany(DiaryNote, { as: 'diaryNotes', foreignKey: 'diaryDateId' });
DiaryNote.belongsTo(DiaryDate, { foreignKey: 'diaryDateId' });
@@ -422,6 +431,8 @@ export {
MemberContact,
MemberImage,
MemberTtrHistory,
MemberOrder,
MemberOrderHistory,
TrainingGroup,
MemberTrainingGroup,
ClubDisabledPresetGroup,

View File

@@ -0,0 +1,18 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import {
getMemberOrders,
createMemberOrder,
updateMemberOrder,
getGlobalOrders
} from '../controllers/memberOrderController.js';
const router = express.Router();
router.get('/global', authenticate, getGlobalOrders);
router.get('/:clubId/:memberId', authenticate, authorize('members', 'read'), getMemberOrders);
router.post('/:clubId/:memberId', authenticate, authorize('members', 'write'), createMemberOrder);
router.patch('/:clubId/:memberId/:orderId', authenticate, authorize('members', 'write'), updateMemberOrder);
export default router;

View File

@@ -14,6 +14,7 @@ import {
PredefinedActivity, PredefinedActivityImage, DiaryDateActivity, DiaryMemberActivity, Match, League, Team, ClubTeam, TeamDocument, Group,
GroupActivity, Tournament, TournamentGroup, TournamentMatch, TournamentResult,
TournamentMember, Accident, UserToken, OfficialTournament, OfficialCompetition, OfficialCompetitionMember, MyTischtennis, ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberTtrHistory
, MemberOrder, MemberOrderHistory
} from './models/index.js';
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
@@ -54,6 +55,7 @@ import clickTtHttpPageRoutes from './routes/clickTtHttpPageRoutes.js';
import memberTransferConfigRoutes from './routes/memberTransferConfigRoutes.js';
import trainingGroupRoutes from './routes/trainingGroupRoutes.js';
import trainingTimeRoutes from './routes/trainingTimeRoutes.js';
import memberOrderRoutes from './routes/memberOrderRoutes.js';
import schedulerService from './services/schedulerService.js';
import { requestLoggingMiddleware } from './middleware/requestLoggingMiddleware.js';
import HttpError from './exceptions/HttpError.js';
@@ -208,6 +210,7 @@ app.use('/api/clicktt', clickTtHttpPageRoutes);
app.use('/api/member-transfer-config', memberTransferConfigRoutes);
app.use('/api/training-groups', trainingGroupRoutes);
app.use('/api/training-times', trainingTimeRoutes);
app.use('/api/member-orders', memberOrderRoutes);
// Middleware für dynamischen kanonischen Tag (vor express.static)
const setCanonicalTag = (req, res, next) => {
@@ -443,6 +446,8 @@ app.use((err, req, res, next) => {
await safeSync(MemberTransferConfig);
await safeSync(MemberContact);
await safeSync(MemberTtrHistory);
await safeSync(MemberOrder);
await safeSync(MemberOrderHistory);
await safeSync(ClubTeam);
await safeSync(TeamDocument);

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();