From 02f1bed4521bc98503a66bd84838ab1425f4cc0e Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 24 Mar 2026 17:01:57 +0100 Subject: [PATCH] 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. --- backend/controllers/memberOrderController.js | 55 ++ .../20260324_create_member_orders_tables.sql | 36 ++ backend/models/MemberOrder.js | 64 +++ backend/models/MemberOrderHistory.js | 63 +++ backend/models/index.js | 11 + backend/routes/memberOrderRoutes.js | 18 + backend/server.js | 5 + backend/services/memberOrderService.js | 296 +++++++++++ docs/manual_sql_migrations.md | 52 ++ frontend/src/App.vue | 4 + .../src/components/DiaryParticipantsPanel.vue | 2 + .../src/components/MemberOrdersDialog.vue | 56 ++ frontend/src/components/OrdersPanel.vue | 503 ++++++++++++++++++ frontend/src/i18n/locales/de-CH.json | 33 ++ frontend/src/i18n/locales/de-extended.json | 33 ++ frontend/src/i18n/locales/de.json | 33 ++ frontend/src/i18n/locales/en-AU.json | 33 ++ frontend/src/i18n/locales/en-GB.json | 33 ++ frontend/src/i18n/locales/en-US.json | 33 ++ frontend/src/i18n/locales/es.json | 33 ++ frontend/src/i18n/locales/fil.json | 33 ++ frontend/src/i18n/locales/fr.json | 33 ++ frontend/src/i18n/locales/it.json | 33 ++ frontend/src/i18n/locales/ja.json | 33 ++ frontend/src/i18n/locales/pl.json | 33 ++ frontend/src/i18n/locales/th.json | 33 ++ frontend/src/i18n/locales/tl.json | 33 ++ frontend/src/i18n/locales/zh.json | 33 ++ frontend/src/router.js | 2 + frontend/src/views/DiaryView.vue | 21 + frontend/src/views/MembersView.vue | 25 + frontend/src/views/OrdersView.vue | 35 ++ 32 files changed, 1743 insertions(+) create mode 100644 backend/controllers/memberOrderController.js create mode 100644 backend/migrations/20260324_create_member_orders_tables.sql create mode 100644 backend/models/MemberOrder.js create mode 100644 backend/models/MemberOrderHistory.js create mode 100644 backend/routes/memberOrderRoutes.js create mode 100644 backend/services/memberOrderService.js create mode 100644 frontend/src/components/MemberOrdersDialog.vue create mode 100644 frontend/src/components/OrdersPanel.vue create mode 100644 frontend/src/views/OrdersView.vue diff --git a/backend/controllers/memberOrderController.js b/backend/controllers/memberOrderController.js new file mode 100644 index 00000000..3ae311bd --- /dev/null +++ b/backend/controllers/memberOrderController.js @@ -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 +}; diff --git a/backend/migrations/20260324_create_member_orders_tables.sql b/backend/migrations/20260324_create_member_orders_tables.sql new file mode 100644 index 00000000..1105d383 --- /dev/null +++ b/backend/migrations/20260324_create_member_orders_tables.sql @@ -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) +); diff --git a/backend/models/MemberOrder.js b/backend/models/MemberOrder.js new file mode 100644 index 00000000..d3d892ea --- /dev/null +++ b/backend/models/MemberOrder.js @@ -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; diff --git a/backend/models/MemberOrderHistory.js b/backend/models/MemberOrderHistory.js new file mode 100644 index 00000000..f8445a42 --- /dev/null +++ b/backend/models/MemberOrderHistory.js @@ -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; diff --git a/backend/models/index.js b/backend/models/index.js index fa72c51a..54609ff3 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, diff --git a/backend/routes/memberOrderRoutes.js b/backend/routes/memberOrderRoutes.js new file mode 100644 index 00000000..5ac9b55b --- /dev/null +++ b/backend/routes/memberOrderRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index fa4dbaeb..b10592a3 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/backend/services/memberOrderService.js b/backend/services/memberOrderService.js new file mode 100644 index 00000000..c9e717cd --- /dev/null +++ b/backend/services/memberOrderService.js @@ -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(); diff --git a/docs/manual_sql_migrations.md b/docs/manual_sql_migrations.md index a9e729e6..2ca2c7c3 100644 --- a/docs/manual_sql_migrations.md +++ b/docs/manual_sql_migrations.md @@ -67,3 +67,55 @@ ALTER TABLE participants Rueckwaertskompatibilitaet: - Bestehende Datensaetze bleiben durch den Default `present` gueltig. + +## 2026-03-24 + +### `member_orders` + +```sql +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) +); +``` + +### `member_order_history` + +```sql +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) +); +``` + +Rueckwaertskompatibilitaet: + +- Das Feature ist additiv. Bestehende Mitglieder- und Vereinstabellen werden nicht veraendert. +- Das Frontend funktioniert auch ohne Bestelldaten; die neuen Tabellen werden nur fuer das Feature genutzt. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 26aac7ca..c8628eeb 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -22,6 +22,10 @@ 🏓 {{ $t('navigation.clickTtAccount') }} + + 📦 + {{ $t('navigation.orders') }} + + @@ -440,6 +441,7 @@ {{ clickTtPendingMemberIds.includes(member.id) ? '⏳' : '🏓' }} +