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') }} + 🔐 {{ $t('navigation.permissions') }} diff --git a/frontend/src/components/DiaryParticipantsPanel.vue b/frontend/src/components/DiaryParticipantsPanel.vue index 6d5335da..82cd734d 100644 --- a/frontend/src/components/DiaryParticipantsPanel.vue +++ b/frontend/src/components/DiaryParticipantsPanel.vue @@ -80,6 +80,7 @@ > 📄 + 📦 ℹ️ @@ -136,6 +137,7 @@ export default { 'open-notes', 'show-pic', 'mark-form', + 'open-orders', 'open-tags', 'open-quick-add', 'open-gallery' diff --git a/frontend/src/components/MemberOrdersDialog.vue b/frontend/src/components/MemberOrdersDialog.vue new file mode 100644 index 00000000..9e72e3d5 --- /dev/null +++ b/frontend/src/components/MemberOrdersDialog.vue @@ -0,0 +1,56 @@ + + + + + + {{ $t('common.close') }} + + + + + + diff --git a/frontend/src/components/OrdersPanel.vue b/frontend/src/components/OrdersPanel.vue new file mode 100644 index 00000000..140c7d9f --- /dev/null +++ b/frontend/src/components/OrdersPanel.vue @@ -0,0 +1,503 @@ + + + + + + + {{ $t('orders.filterAllStatuses') }} + + {{ status.label }} + + + + {{ $t('orders.filterAllClubs') }} + + {{ club.name }} + + + + + {{ $t('common.refresh') }} + + + + + + + {{ $t('orders.item') }} + + + + {{ $t('orders.cost') }} + + + + {{ $t('orders.paid') }} + + + + {{ $t('orders.status') }} + + + {{ status.label }} + + + + + + + {{ $t('orders.addOrder') }} + + {{ $t('orders.dateAutoHint') }} + + + + {{ $t('orders.loading') }} + {{ error }} + + {{ globalMode ? $t('orders.noOrdersGlobal') : $t('orders.noOrdersMember') }} + + + + + + + {{ $t('orders.club') }} + {{ $t('orders.member') }} + {{ $t('orders.item') }} + {{ $t('orders.status') }} + {{ $t('orders.orderDate') }} + {{ $t('orders.statusDate') }} + {{ $t('orders.cost') }} + {{ $t('orders.paid') }} + {{ $t('orders.open') }} + {{ $t('orders.history') }} + {{ $t('common.save') }} + + + + + {{ order.club?.name || '–' }} + {{ formatMemberName(order.member) }} + + + + + + + {{ status.label }} + + + + {{ formatDateTime(order.orderDate || order.createdAt) }} + {{ formatDateTime(order.statusDate || order.updatedAt) }} + + + + + + + {{ formatCurrency(calculateOpenAmount(order)) }} + + + {{ order.historyEntries?.length || 0 }} + + + {{ statusLabel(entry.status) }} + {{ formatDateTime(entry.changedAt) }} + {{ formatCurrency(entry.cost) }} / {{ formatCurrency(entry.paidAmount) }} + + + + + + + {{ savingOrderIds.includes(order.id) ? $t('common.loading') : $t('common.save') }} + + + + + + + + + + + + diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json index b654c51b..87858706 100644 --- a/frontend/src/i18n/locales/de-CH.json +++ b/frontend/src/i18n/locales/de-CH.json @@ -28,6 +28,7 @@ "time": "Zyt", "new": "Neu", "update": "Aktualisiere", + "refresh": "Neu lade", "create": "Erstelle", "remove": "Entferne", "select": "Uswähle", @@ -61,6 +62,7 @@ "navigation": { "home": "Startseite", "members": "Mitglider", + "orders": "Bestellige", "diary": "Tagebuech", "approvals": "Freigabe", "statistics": "Trainings-Statistik", @@ -623,6 +625,37 @@ "copyContactSummary": "Kontaktübersicht kopieren", "copyContactSummarySuccess": "Kontaktübersicht in die Zwischenablage kopiert." }, + "orders": { + "title": "Bestellige", + "memberTitle": "Bestellige: {name}", + "globalTitle": "Bestellige aller Vereine", + "globalSubtitle": "Da chasch alli Bestellige vereinsübergreifend aluege und verwalte.", + "loading": "Bestellige werde glade...", + "errorLoading": "Bestellige hend nöd chöne glade werde.", + "errorSaving": "Bestellig het nöd chöne gspeicheret werde.", + "searchPlaceholder": "Nach Verein, Mitgliid oder Artikel sueche", + "filterAllStatuses": "Alli Status", + "filterAllClubs": "Alli Vereine", + "item": "Was", + "itemPlaceholder": "z. B. Trikot, Hoodie oder Schlägerhülle", + "status": "Status", + "statusRequested": "gwünscht", + "statusOrdered": "bstellt", + "statusArrived": "Artikel achoo", + "statusHandedOver": "Artikel usghändigt", + "cost": "Chöschte", + "paid": "Bezahlt", + "open": "No offe", + "history": "Verlauf", + "orderDate": "Erfasst am", + "statusDate": "Letschti Änderig", + "addOrder": "Bestellig aalege", + "dateAutoHint": "S Datum wird automatisch gsetzt und jedi Änderig mit em Datum protokolliert.", + "noOrdersMember": "Für das Mitgliid git's no kei Bestellige.", + "noOrdersGlobal": "Aktuell git's kei Bestellige.", + "club": "Verein", + "member": "Mitgliid" + }, "diary": { "title": "Trainingstagebuch", "date": "Datum", diff --git a/frontend/src/i18n/locales/de-extended.json b/frontend/src/i18n/locales/de-extended.json index e0275a6b..72775711 100644 --- a/frontend/src/i18n/locales/de-extended.json +++ b/frontend/src/i18n/locales/de-extended.json @@ -26,6 +26,7 @@ "today": "Heute", "new": "Neu", "update": "Aktualisieren", + "refresh": "Neu laden", "create": "Erstellen", "remove": "Entfernen", "select": "Auswählen", @@ -57,6 +58,7 @@ "navigation": { "home": "Startseite", "members": "Mitglieder", + "orders": "Bestellungen", "diary": "Tagebuch", "approvals": "Freigaben", "statistics": "Trainings-Statistik", @@ -294,6 +296,37 @@ "copyContactSummary": "Kontaktübersicht kopieren", "copyContactSummarySuccess": "Kontaktübersicht in die Zwischenablage kopiert." }, + "orders": { + "title": "Bestellungen", + "memberTitle": "Bestellungen: {name}", + "globalTitle": "Bestellungen aller Vereine", + "globalSubtitle": "Hier werden alle Bestellungen vereinsübergreifend angezeigt und verwaltet.", + "loading": "Lade Bestellungen...", + "errorLoading": "Bestellungen konnten nicht geladen werden.", + "errorSaving": "Bestellung konnte nicht gespeichert werden.", + "searchPlaceholder": "Nach Verein, Mitglied oder Artikel suchen", + "filterAllStatuses": "Alle Status", + "filterAllClubs": "Alle Vereine", + "item": "Was", + "itemPlaceholder": "z. B. Trikot, Hoodie oder Schlägerhülle", + "status": "Status", + "statusRequested": "gewünscht", + "statusOrdered": "bestellt", + "statusArrived": "Artikel angekommen", + "statusHandedOver": "Artikel ausgehändigt", + "cost": "Kosten", + "paid": "Bezahlt", + "open": "Noch offen", + "history": "Verlauf", + "orderDate": "Erfasst am", + "statusDate": "Letzte Änderung", + "addOrder": "Bestellung anlegen", + "dateAutoHint": "Datum wird automatisch gesetzt und jede Änderung mit Datum protokolliert.", + "noOrdersMember": "Für dieses Mitglied gibt es noch keine Bestellungen.", + "noOrdersGlobal": "Es gibt aktuell keine Bestellungen.", + "club": "Verein", + "member": "Mitglied" + }, "diary": { "title": "Trainingstagebuch", "date": "Datum", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index df24ba8c..d55d51f6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -28,6 +28,7 @@ "time": "Zeit", "new": "Neu", "update": "Aktualisieren", + "refresh": "Neu laden", "create": "Erstellen", "remove": "Entfernen", "select": "Auswählen", @@ -61,6 +62,7 @@ "navigation": { "home": "Startseite", "members": "Mitglieder", + "orders": "Bestellungen", "diary": "Tagebuch", "approvals": "Freigaben", "statistics": "Trainings-Statistik", @@ -400,6 +402,37 @@ "copyContactSummary": "Kontaktübersicht kopieren", "copyContactSummarySuccess": "Kontaktübersicht in die Zwischenablage kopiert." }, + "orders": { + "title": "Bestellungen", + "memberTitle": "Bestellungen: {name}", + "globalTitle": "Bestellungen aller Vereine", + "globalSubtitle": "Hier werden alle Bestellungen vereinsübergreifend angezeigt und verwaltet.", + "loading": "Lade Bestellungen...", + "errorLoading": "Bestellungen konnten nicht geladen werden.", + "errorSaving": "Bestellung konnte nicht gespeichert werden.", + "searchPlaceholder": "Nach Verein, Mitglied oder Artikel suchen", + "filterAllStatuses": "Alle Status", + "filterAllClubs": "Alle Vereine", + "item": "Was", + "itemPlaceholder": "z. B. Trikot, Hoodie oder Schlägerhülle", + "status": "Status", + "statusRequested": "gewünscht", + "statusOrdered": "bestellt", + "statusArrived": "Artikel angekommen", + "statusHandedOver": "Artikel ausgehändigt", + "cost": "Kosten", + "paid": "Bezahlt", + "open": "Noch offen", + "history": "Verlauf", + "orderDate": "Erfasst am", + "statusDate": "Letzte Änderung", + "addOrder": "Bestellung anlegen", + "dateAutoHint": "Datum wird automatisch gesetzt und jede Änderung mit Datum protokolliert.", + "noOrdersMember": "Für dieses Mitglied gibt es noch keine Bestellungen.", + "noOrdersGlobal": "Es gibt aktuell keine Bestellungen.", + "club": "Verein", + "member": "Mitglied" + }, "diary": { "title": "Trainingstagebuch", "date": "Datum", diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json index f189e86f..681b6602 100644 --- a/frontend/src/i18n/locales/en-AU.json +++ b/frontend/src/i18n/locales/en-AU.json @@ -29,6 +29,7 @@ "time": "Time", "new": "New", "update": "Update", + "refresh": "Reload", "create": "Create", "remove": "Remove", "select": "Select", @@ -61,6 +62,7 @@ "navigation": { "home": "Home", "members": "Members", + "orders": "Orders", "diary": "Diary", "approvals": "Approvals", "statistics": "Training Statistics", @@ -623,6 +625,37 @@ "copyContactSummary": "Copy contact summary", "copyContactSummarySuccess": "Contact summary copied to clipboard." }, + "orders": { + "title": "Orders", + "memberTitle": "Orders: {name}", + "globalTitle": "Orders Across All Clubs", + "globalSubtitle": "All orders across clubs can be viewed and managed here.", + "loading": "Loading orders...", + "errorLoading": "Orders could not be loaded.", + "errorSaving": "Order could not be saved.", + "searchPlaceholder": "Search by club, member or item", + "filterAllStatuses": "All statuses", + "filterAllClubs": "All clubs", + "item": "Item", + "itemPlaceholder": "e.g. shirt, hoodie or bat cover", + "status": "Status", + "statusRequested": "requested", + "statusOrdered": "ordered", + "statusArrived": "item arrived", + "statusHandedOver": "item handed over", + "cost": "Cost", + "paid": "Paid", + "open": "Outstanding", + "history": "History", + "orderDate": "Created on", + "statusDate": "Last change", + "addOrder": "Create order", + "dateAutoHint": "The date is set automatically and every change is logged with a date.", + "noOrdersMember": "There are no orders for this member yet.", + "noOrdersGlobal": "There are currently no orders.", + "club": "Club", + "member": "Member" + }, "diary": { "title": "Training diary", "date": "Date", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index 461d6e3e..059fcedb 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -29,6 +29,7 @@ "time": "Time", "new": "New", "update": "Update", + "refresh": "Reload", "create": "Create", "remove": "Remove", "select": "Select", @@ -61,6 +62,7 @@ "navigation": { "home": "Home", "members": "Members", + "orders": "Orders", "diary": "Diary", "approvals": "Approvals", "statistics": "Training Statistics", @@ -176,6 +178,37 @@ "confirm": "Confirm", "cancel": "Cancel" }, + "orders": { + "title": "Orders", + "memberTitle": "Orders: {name}", + "globalTitle": "Orders Across All Clubs", + "globalSubtitle": "All orders across clubs can be viewed and managed here.", + "loading": "Loading orders...", + "errorLoading": "Orders could not be loaded.", + "errorSaving": "Order could not be saved.", + "searchPlaceholder": "Search by club, member or item", + "filterAllStatuses": "All statuses", + "filterAllClubs": "All clubs", + "item": "Item", + "itemPlaceholder": "e.g. shirt, hoodie or bat cover", + "status": "Status", + "statusRequested": "requested", + "statusOrdered": "ordered", + "statusArrived": "item arrived", + "statusHandedOver": "item handed over", + "cost": "Cost", + "paid": "Paid", + "open": "Outstanding", + "history": "History", + "orderDate": "Created on", + "statusDate": "Last change", + "addOrder": "Create order", + "dateAutoHint": "The date is set automatically and every change is logged with a date.", + "noOrdersMember": "There are no orders for this member yet.", + "noOrdersGlobal": "There are currently no orders.", + "club": "Club", + "member": "Member" + }, "diary": { "title": "Training diary", "date": "Date", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index 96ce4c22..81593519 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -29,6 +29,7 @@ "time": "Time", "new": "New", "update": "Update", + "refresh": "Reload", "create": "Create", "remove": "Remove", "select": "Select", @@ -61,6 +62,7 @@ "navigation": { "home": "Home", "members": "Members", + "orders": "Orders", "diary": "Diary", "approvals": "Approvals", "statistics": "Training Statistics", @@ -623,6 +625,37 @@ "copyContactSummary": "Copy contact summary", "copyContactSummarySuccess": "Contact summary copied to clipboard." }, + "orders": { + "title": "Orders", + "memberTitle": "Orders: {name}", + "globalTitle": "Orders Across All Clubs", + "globalSubtitle": "All orders across clubs can be viewed and managed here.", + "loading": "Loading orders...", + "errorLoading": "Orders could not be loaded.", + "errorSaving": "Order could not be saved.", + "searchPlaceholder": "Search by club, member, or item", + "filterAllStatuses": "All statuses", + "filterAllClubs": "All clubs", + "item": "Item", + "itemPlaceholder": "e.g. jersey, hoodie, or paddle case", + "status": "Status", + "statusRequested": "requested", + "statusOrdered": "ordered", + "statusArrived": "item arrived", + "statusHandedOver": "item handed over", + "cost": "Cost", + "paid": "Paid", + "open": "Outstanding", + "history": "History", + "orderDate": "Created on", + "statusDate": "Last update", + "addOrder": "Create order", + "dateAutoHint": "The date is set automatically and every change is logged with a date.", + "noOrdersMember": "There are no orders for this member yet.", + "noOrdersGlobal": "There are currently no orders.", + "club": "Club", + "member": "Member" + }, "diary": { "title": "Training diary", "date": "Date", diff --git a/frontend/src/i18n/locales/es.json b/frontend/src/i18n/locales/es.json index 72ec6543..7fb3e132 100644 --- a/frontend/src/i18n/locales/es.json +++ b/frontend/src/i18n/locales/es.json @@ -28,6 +28,7 @@ "time": "Hora", "new": "Nuevo", "update": "Actualizar", + "refresh": "Recargar", "create": "Crear", "remove": "Quitar", "select": "Seleccionar", @@ -61,6 +62,7 @@ "navigation": { "home": "Inicio", "members": "Miembros", + "orders": "Pedidos", "diary": "Diario", "approvals": "Aprobaciones", "statistics": "Estadísticas de entrenamiento", @@ -595,6 +597,37 @@ "copyContactSummary": "Copiar resumen de contactos", "copyContactSummarySuccess": "Resumen de contactos copiado al portapapeles." }, + "orders": { + "title": "Pedidos", + "memberTitle": "Pedidos: {name}", + "globalTitle": "Pedidos de todos los clubes", + "globalSubtitle": "Aquí se pueden ver y gestionar los pedidos de todos los clubes.", + "loading": "Cargando pedidos...", + "errorLoading": "No se pudieron cargar los pedidos.", + "errorSaving": "No se pudo guardar el pedido.", + "searchPlaceholder": "Buscar por club, miembro o artículo", + "filterAllStatuses": "Todos los estados", + "filterAllClubs": "Todos los clubes", + "item": "Artículo", + "itemPlaceholder": "p. ej. camiseta, sudadera o funda de pala", + "status": "Estado", + "statusRequested": "deseado", + "statusOrdered": "pedido", + "statusArrived": "artículo recibido", + "statusHandedOver": "artículo entregado", + "cost": "Coste", + "paid": "Pagado", + "open": "Pendiente", + "history": "Historial", + "orderDate": "Creado el", + "statusDate": "Último cambio", + "addOrder": "Crear pedido", + "dateAutoHint": "La fecha se establece automáticamente y cada cambio se registra con fecha.", + "noOrdersMember": "Todavía no hay pedidos para este miembro.", + "noOrdersGlobal": "Actualmente no hay pedidos.", + "club": "Club", + "member": "Miembro" + }, "diary": { "title": "Diario de entrenamiento", "date": "Fecha", diff --git a/frontend/src/i18n/locales/fil.json b/frontend/src/i18n/locales/fil.json index e02f5a64..56e53d4c 100644 --- a/frontend/src/i18n/locales/fil.json +++ b/frontend/src/i18n/locales/fil.json @@ -28,6 +28,7 @@ "time": "Oras", "new": "Bago", "update": "I-update", + "refresh": "I-reload", "create": "Lumikha", "remove": "Alisin", "select": "Pumili", @@ -61,6 +62,7 @@ "navigation": { "home": "Home", "members": "Mga miyembro", + "orders": "Mga order", "diary": "Talaarawan", "approvals": "Mga pag-apruba", "statistics": "Istatistika ng pagsasanay", @@ -595,6 +597,37 @@ "copyContactSummary": "Copy contact summary", "copyContactSummarySuccess": "Contact summary copied to clipboard." }, + "orders": { + "title": "Mga order", + "memberTitle": "Mga order: {name}", + "globalTitle": "Mga order ng lahat ng club", + "globalSubtitle": "Dito makikita at mapapamahalaan ang lahat ng order mula sa lahat ng club.", + "loading": "Nilo-load ang mga order...", + "errorLoading": "Hindi ma-load ang mga order.", + "errorSaving": "Hindi ma-save ang order.", + "searchPlaceholder": "Maghanap ayon sa club, miyembro, o item", + "filterAllStatuses": "Lahat ng status", + "filterAllClubs": "Lahat ng club", + "item": "Item", + "itemPlaceholder": "hal. jersey, hoodie, o lagayan ng raketa", + "status": "Status", + "statusRequested": "ninanais", + "statusOrdered": "na-order na", + "statusArrived": "dumating na ang item", + "statusHandedOver": "naibigay na ang item", + "cost": "Halaga", + "paid": "Bayad", + "open": "Natitira", + "history": "Kasaysayan", + "orderDate": "Ginawa noong", + "statusDate": "Huling pagbabago", + "addOrder": "Gumawa ng order", + "dateAutoHint": "Awtomatikong itinatakda ang petsa at bawat pagbabago ay sine-save kasama ang petsa.", + "noOrdersMember": "Wala pang order para sa miyembrong ito.", + "noOrdersGlobal": "Wala pang mga order sa ngayon.", + "club": "Club", + "member": "Miyembro" + }, "diary": { "title": "Talaarawan ng pagsasanay", "date": "Petsa", diff --git a/frontend/src/i18n/locales/fr.json b/frontend/src/i18n/locales/fr.json index 65179a6e..c5d09b0d 100644 --- a/frontend/src/i18n/locales/fr.json +++ b/frontend/src/i18n/locales/fr.json @@ -28,6 +28,7 @@ "time": "Heure", "new": "Nouveau", "update": "Mettre à jour", + "refresh": "Recharger", "create": "Créer", "remove": "Retirer", "select": "Sélectionner", @@ -61,6 +62,7 @@ "navigation": { "home": "Accueil", "members": "Membres", + "orders": "Commandes", "diary": "Journal", "approvals": "Approbations", "statistics": "Statistiques d'entraînement", @@ -595,6 +597,37 @@ "copyContactSummary": "Copier le résumé des contacts", "copyContactSummarySuccess": "Résumé des contacts copié dans le presse-papiers." }, + "orders": { + "title": "Commandes", + "memberTitle": "Commandes : {name}", + "globalTitle": "Commandes de tous les clubs", + "globalSubtitle": "Toutes les commandes de tous les clubs peuvent être consultées et gérées ici.", + "loading": "Chargement des commandes...", + "errorLoading": "Impossible de charger les commandes.", + "errorSaving": "Impossible d'enregistrer la commande.", + "searchPlaceholder": "Rechercher un club, un membre ou un article", + "filterAllStatuses": "Tous les statuts", + "filterAllClubs": "Tous les clubs", + "item": "Article", + "itemPlaceholder": "p. ex. maillot, hoodie ou housse de raquette", + "status": "Statut", + "statusRequested": "souhaité", + "statusOrdered": "commandé", + "statusArrived": "article arrivé", + "statusHandedOver": "article remis", + "cost": "Coût", + "paid": "Payé", + "open": "Reste à payer", + "history": "Historique", + "orderDate": "Créé le", + "statusDate": "Dernière modification", + "addOrder": "Créer une commande", + "dateAutoHint": "La date est définie automatiquement et chaque modification est enregistrée avec une date.", + "noOrdersMember": "Aucune commande n'existe encore pour ce membre.", + "noOrdersGlobal": "Il n'y a actuellement aucune commande.", + "club": "Club", + "member": "Membre" + }, "diary": { "title": "Journal d'entraînement", "date": "Date", diff --git a/frontend/src/i18n/locales/it.json b/frontend/src/i18n/locales/it.json index 44732ca6..ebcc9b2f 100644 --- a/frontend/src/i18n/locales/it.json +++ b/frontend/src/i18n/locales/it.json @@ -28,6 +28,7 @@ "time": "Ora", "new": "Nuovo", "update": "Aggiorna", + "refresh": "Ricarica", "create": "Crea", "remove": "Rimuovi", "select": "Seleziona", @@ -61,6 +62,7 @@ "navigation": { "home": "Home", "members": "Membri", + "orders": "Ordini", "diary": "Diario", "approvals": "Approvazioni", "statistics": "Statistiche di allenamento", @@ -595,6 +597,37 @@ "copyContactSummary": "Copia riepilogo contatti", "copyContactSummarySuccess": "Riepilogo contatti copiato negli appunti." }, + "orders": { + "title": "Ordini", + "memberTitle": "Ordini: {name}", + "globalTitle": "Ordini di tutti i club", + "globalSubtitle": "Qui è possibile visualizzare e gestire tutti gli ordini di tutti i club.", + "loading": "Caricamento ordini...", + "errorLoading": "Impossibile caricare gli ordini.", + "errorSaving": "Impossibile salvare l'ordine.", + "searchPlaceholder": "Cerca per club, membro o articolo", + "filterAllStatuses": "Tutti gli stati", + "filterAllClubs": "Tutti i club", + "item": "Articolo", + "itemPlaceholder": "ad es. maglia, felpa o custodia per racchetta", + "status": "Stato", + "statusRequested": "richiesto", + "statusOrdered": "ordinato", + "statusArrived": "articolo arrivato", + "statusHandedOver": "articolo consegnato", + "cost": "Costo", + "paid": "Pagato", + "open": "Da pagare", + "history": "Cronologia", + "orderDate": "Creato il", + "statusDate": "Ultima modifica", + "addOrder": "Crea ordine", + "dateAutoHint": "La data viene impostata automaticamente e ogni modifica viene registrata con una data.", + "noOrdersMember": "Non ci sono ancora ordini per questo membro.", + "noOrdersGlobal": "Attualmente non ci sono ordini.", + "club": "Club", + "member": "Membro" + }, "diary": { "title": "Diario di allenamento", "date": "Data", diff --git a/frontend/src/i18n/locales/ja.json b/frontend/src/i18n/locales/ja.json index d0773821..d79c05b2 100644 --- a/frontend/src/i18n/locales/ja.json +++ b/frontend/src/i18n/locales/ja.json @@ -28,6 +28,7 @@ "time": "時刻", "new": "新規", "update": "更新", + "refresh": "再読み込み", "create": "作成", "remove": "削除", "select": "選択", @@ -61,6 +62,7 @@ "navigation": { "home": "ホーム", "members": "メンバー", + "orders": "注文", "diary": "日記", "approvals": "承認", "statistics": "トレーニング統計", @@ -595,6 +597,37 @@ "copyContactSummary": "Copy contact summary", "copyContactSummarySuccess": "Contact summary copied to clipboard." }, + "orders": { + "title": "注文", + "memberTitle": "注文: {name}", + "globalTitle": "全クラブの注文", + "globalSubtitle": "ここではクラブ横断で全ての注文を表示・管理できます。", + "loading": "注文を読み込み中...", + "errorLoading": "注文を読み込めませんでした。", + "errorSaving": "注文を保存できませんでした。", + "searchPlaceholder": "クラブ、メンバー、または商品で検索", + "filterAllStatuses": "すべての状態", + "filterAllClubs": "すべてのクラブ", + "item": "商品", + "itemPlaceholder": "例: シャツ、パーカー、ラケットケース", + "status": "状態", + "statusRequested": "希望", + "statusOrdered": "注文済み", + "statusArrived": "商品到着", + "statusHandedOver": "商品引き渡し済み", + "cost": "費用", + "paid": "支払済み", + "open": "未払い", + "history": "履歴", + "orderDate": "登録日", + "statusDate": "最終変更", + "addOrder": "注文を追加", + "dateAutoHint": "日付は自動設定され、すべての変更が日付付きで記録されます。", + "noOrdersMember": "このメンバーにはまだ注文がありません。", + "noOrdersGlobal": "現在注文はありません。", + "club": "クラブ", + "member": "メンバー" + }, "diary": { "title": "練習日誌", "date": "日付", diff --git a/frontend/src/i18n/locales/pl.json b/frontend/src/i18n/locales/pl.json index 50c67122..4a71f950 100644 --- a/frontend/src/i18n/locales/pl.json +++ b/frontend/src/i18n/locales/pl.json @@ -28,6 +28,7 @@ "time": "Czas", "new": "Nowy", "update": "Aktualizuj", + "refresh": "Odśwież", "create": "Utwórz", "remove": "Usuń", "select": "Wybierz", @@ -61,6 +62,7 @@ "navigation": { "home": "Strona główna", "members": "Członkowie", + "orders": "Zamówienia", "diary": "Dziennik", "approvals": "Zatwierdzenia", "statistics": "Statystyki treningowe", @@ -595,6 +597,37 @@ "copyContactSummary": "Kopiuj podsumowanie kontaktów", "copyContactSummarySuccess": "Podsumowanie kontaktów skopiowano do schowka." }, + "orders": { + "title": "Zamówienia", + "memberTitle": "Zamówienia: {name}", + "globalTitle": "Zamówienia ze wszystkich klubów", + "globalSubtitle": "Tutaj można przeglądać i zarządzać zamówieniami ze wszystkich klubów.", + "loading": "Ładowanie zamówień...", + "errorLoading": "Nie udało się załadować zamówień.", + "errorSaving": "Nie udało się zapisać zamówienia.", + "searchPlaceholder": "Szukaj po klubie, członku lub artykule", + "filterAllStatuses": "Wszystkie statusy", + "filterAllClubs": "Wszystkie kluby", + "item": "Artykuł", + "itemPlaceholder": "np. koszulka, bluza lub pokrowiec na rakietkę", + "status": "Status", + "statusRequested": "pożądane", + "statusOrdered": "zamówione", + "statusArrived": "artykuł dotarł", + "statusHandedOver": "artykuł wydany", + "cost": "Koszt", + "paid": "Zapłacono", + "open": "Pozostało", + "history": "Historia", + "orderDate": "Utworzono", + "statusDate": "Ostatnia zmiana", + "addOrder": "Dodaj zamówienie", + "dateAutoHint": "Data jest ustawiana automatycznie, a każda zmiana jest zapisywana z datą.", + "noOrdersMember": "Ten członek nie ma jeszcze żadnych zamówień.", + "noOrdersGlobal": "Obecnie nie ma żadnych zamówień.", + "club": "Klub", + "member": "Członek" + }, "diary": { "title": "Dziennik treningowy", "date": "Data", diff --git a/frontend/src/i18n/locales/th.json b/frontend/src/i18n/locales/th.json index 2030d690..451f2553 100644 --- a/frontend/src/i18n/locales/th.json +++ b/frontend/src/i18n/locales/th.json @@ -28,6 +28,7 @@ "time": "เวลา", "new": "ใหม่", "update": "อัปเดต", + "refresh": "โหลดใหม่", "create": "สร้าง", "remove": "เอาออก", "select": "เลือก", @@ -61,6 +62,7 @@ "navigation": { "home": "หน้าแรก", "members": "สมาชิก", + "orders": "คำสั่งซื้อ", "diary": "ไดอารี่", "approvals": "การอนุมัติ", "statistics": "สถิติการฝึกซ้อม", @@ -595,6 +597,37 @@ "copyContactSummary": "Copy contact summary", "copyContactSummarySuccess": "Contact summary copied to clipboard." }, + "orders": { + "title": "คำสั่งซื้อ", + "memberTitle": "คำสั่งซื้อ: {name}", + "globalTitle": "คำสั่งซื้อของทุกสโมสร", + "globalSubtitle": "สามารถดูและจัดการคำสั่งซื้อทั้งหมดข้ามทุกสโมสรได้ที่นี่", + "loading": "กำลังโหลดคำสั่งซื้อ...", + "errorLoading": "ไม่สามารถโหลดคำสั่งซื้อได้", + "errorSaving": "ไม่สามารถบันทึกคำสั่งซื้อได้", + "searchPlaceholder": "ค้นหาตามสโมสร สมาชิก หรือสินค้า", + "filterAllStatuses": "ทุกสถานะ", + "filterAllClubs": "ทุกสโมสร", + "item": "สินค้า", + "itemPlaceholder": "เช่น เสื้อแข่ง ฮู้ดดี้ หรือซองไม้", + "status": "สถานะ", + "statusRequested": "ต้องการ", + "statusOrdered": "สั่งแล้ว", + "statusArrived": "สินค้ามาถึงแล้ว", + "statusHandedOver": "ส่งมอบสินค้าแล้ว", + "cost": "ค่าใช้จ่าย", + "paid": "ชำระแล้ว", + "open": "ค้างชำระ", + "history": "ประวัติ", + "orderDate": "วันที่สร้าง", + "statusDate": "การเปลี่ยนแปลงล่าสุด", + "addOrder": "สร้างคำสั่งซื้อ", + "dateAutoHint": "ระบบจะกำหนดวันที่ให้อัตโนมัติ และทุกการเปลี่ยนแปลงจะถูกบันทึกพร้อมวันที่", + "noOrdersMember": "ยังไม่มีคำสั่งซื้อสำหรับสมาชิกคนนี้", + "noOrdersGlobal": "ขณะนี้ยังไม่มีคำสั่งซื้อ", + "club": "สโมสร", + "member": "สมาชิก" + }, "diary": { "title": "สมุดบันทึกการฝึกซ้อม", "date": "วันที่", diff --git a/frontend/src/i18n/locales/tl.json b/frontend/src/i18n/locales/tl.json index d645e07c..bceb421d 100644 --- a/frontend/src/i18n/locales/tl.json +++ b/frontend/src/i18n/locales/tl.json @@ -28,6 +28,7 @@ "time": "Oras", "new": "Bago", "update": "I-update", + "refresh": "I-reload", "create": "Lumikha", "remove": "Alisin", "select": "Pumili", @@ -61,6 +62,7 @@ "navigation": { "home": "Home", "members": "Mga miyembro", + "orders": "Mga order", "diary": "Talaarawan", "approvals": "Mga pag-apruba", "statistics": "Mga istatistika ng pagsasanay", @@ -595,6 +597,37 @@ "copyContactSummary": "Copy contact summary", "copyContactSummarySuccess": "Contact summary copied to clipboard." }, + "orders": { + "title": "Mga order", + "memberTitle": "Mga order: {name}", + "globalTitle": "Mga order ng lahat ng club", + "globalSubtitle": "Dito makikita at mapapamahalaan ang lahat ng order mula sa iba’t ibang club.", + "loading": "Nilo-load ang mga order...", + "errorLoading": "Hindi ma-load ang mga order.", + "errorSaving": "Hindi ma-save ang order.", + "searchPlaceholder": "Maghanap ayon sa club, miyembro, o item", + "filterAllStatuses": "Lahat ng status", + "filterAllClubs": "Lahat ng club", + "item": "Item", + "itemPlaceholder": "hal. jersey, hoodie, o lagayan ng raketa", + "status": "Status", + "statusRequested": "hinihiling", + "statusOrdered": "na-order na", + "statusArrived": "dumating na ang item", + "statusHandedOver": "naibigay na ang item", + "cost": "Halaga", + "paid": "Nabayaran", + "open": "Natitira", + "history": "Kasaysayan", + "orderDate": "Ginawa noong", + "statusDate": "Huling pagbabago", + "addOrder": "Gumawa ng order", + "dateAutoHint": "Awtomatikong itinatakda ang petsa at bawat pagbabago ay sine-save kasama ang petsa.", + "noOrdersMember": "Wala pang order para sa miyembrong ito.", + "noOrdersGlobal": "Wala pang mga order sa ngayon.", + "club": "Club", + "member": "Miyembro" + }, "diary": { "title": "Talaarawan ng pagsasanay", "date": "Petsa", diff --git a/frontend/src/i18n/locales/zh.json b/frontend/src/i18n/locales/zh.json index dd65393b..a2aa9e1a 100644 --- a/frontend/src/i18n/locales/zh.json +++ b/frontend/src/i18n/locales/zh.json @@ -28,6 +28,7 @@ "time": "时间", "new": "新建", "update": "更新", + "refresh": "重新加载", "create": "创建", "remove": "移除", "select": "选择", @@ -61,6 +62,7 @@ "navigation": { "home": "首页", "members": "成员", + "orders": "订单", "diary": "日记", "approvals": "审批", "statistics": "训练统计", @@ -595,6 +597,37 @@ "copyContactSummary": "Copy contact summary", "copyContactSummarySuccess": "Contact summary copied to clipboard." }, + "orders": { + "title": "订单", + "memberTitle": "订单:{name}", + "globalTitle": "所有俱乐部的订单", + "globalSubtitle": "可在此跨俱乐部查看和管理所有订单。", + "loading": "正在加载订单...", + "errorLoading": "无法加载订单。", + "errorSaving": "无法保存订单。", + "searchPlaceholder": "按俱乐部、成员或商品搜索", + "filterAllStatuses": "所有状态", + "filterAllClubs": "所有俱乐部", + "item": "商品", + "itemPlaceholder": "例如:球衣、卫衣或球拍套", + "status": "状态", + "statusRequested": "希望购买", + "statusOrdered": "已订购", + "statusArrived": "商品已到", + "statusHandedOver": "商品已发放", + "cost": "费用", + "paid": "已支付", + "open": "未结清", + "history": "历史", + "orderDate": "创建时间", + "statusDate": "最后变更", + "addOrder": "新建订单", + "dateAutoHint": "日期会自动设置,并且每次变更都会连同日期一起记录。", + "noOrdersMember": "该成员目前还没有订单。", + "noOrdersGlobal": "当前没有订单。", + "club": "俱乐部", + "member": "成员" + }, "diary": { "title": "训练日记", "date": "日期", diff --git a/frontend/src/router.js b/frontend/src/router.js index 4129e929..83596da8 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -27,6 +27,7 @@ const LogsView = () => import('./views/LogsView.vue'); const ClickTtView = () => import('./views/ClickTtView.vue'); const MemberTransferSettingsView = () => import('./views/MemberTransferSettingsView.vue'); const PersonalSettings = () => import('./views/PersonalSettings.vue'); +const OrdersView = () => import('./views/OrdersView.vue'); const Impressum = () => import('./views/Impressum.vue'); const Datenschutz = () => import('./views/Datenschutz.vue'); @@ -56,6 +57,7 @@ const routes = [ { path: '/clicktt', name: 'clicktt', component: ClickTtView }, { path: '/member-transfer-settings', name: 'member-transfer-settings', component: MemberTransferSettingsView }, { path: '/personal-settings', name: 'personal-settings', component: PersonalSettings }, + { path: '/orders', name: 'orders', component: OrdersView }, { path: '/impressum', name: 'impressum', component: Impressum, meta: { public: true } }, { path: '/datenschutz', name: 'datenschutz', component: Datenschutz, meta: { public: true } }, ]; diff --git a/frontend/src/views/DiaryView.vue b/frontend/src/views/DiaryView.vue index 07c01e96..c1065dbe 100644 --- a/frontend/src/views/DiaryView.vue +++ b/frontend/src/views/DiaryView.vue @@ -566,6 +566,7 @@ @open-notes="openNotesModal" @show-pic="showPic" @mark-form="markFormHandedOver" + @open-orders="openOrdersDialog" @open-tags="openTagInfos" @open-quick-add="openQuickAddDialog" @open-gallery="openGalleryDialog" @@ -671,6 +672,14 @@ :should-show-member="shouldShowGalleryMember" @member-click="handleGalleryMemberClick" /> + + @@ -712,6 +721,7 @@ import MemberActivityStatsDialog from '../components/MemberActivityStatsDialog.v import AccidentFormDialog from '../components/AccidentFormDialog.vue'; import QuickAddMemberDialog from '../components/QuickAddMemberDialog.vue'; import MemberGalleryDialog from '../components/MemberGalleryDialog.vue'; +import MemberOrdersDialog from '../components/MemberOrdersDialog.vue'; import DiaryParticipantsPanel from '../components/DiaryParticipantsPanel.vue'; import DiaryActivitiesPanel from '../components/DiaryActivitiesPanel.vue'; import DiaryOverviewPanels from '../components/diary/DiaryOverviewPanels.vue'; @@ -776,6 +786,7 @@ export default { AccidentFormDialog, QuickAddMemberDialog, MemberGalleryDialog, + MemberOrdersDialog, DiaryParticipantsPanel, DiaryActivitiesPanel, DiaryOverviewPanels @@ -818,8 +829,10 @@ export default { notes: [], newNoteContent: '', noteMember: null, + selectedMemberForOrders: null, selectedMember: null, showNotesModal: false, + showMemberOrdersDialog: false, selectedActivityTags: [], selectedMemberTags: [], selectedMemberDayTags: [], @@ -1797,6 +1810,14 @@ export default { this.loadMemberNotesAndTags(this.date.id, member.id); this.showNotesModal = true; }, + openOrdersDialog(member) { + this.selectedMemberForOrders = member; + this.showMemberOrdersDialog = true; + }, + closeOrdersDialog() { + this.showMemberOrdersDialog = false; + this.selectedMemberForOrders = null; + }, async loadMemberNotesAndTags(diaryDateId, memberId) { this.doMemberTagUpdates = false; diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index bd6a6e73..f5b8acb8 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -74,6 +74,7 @@ {{ $t('members.editMember') }} + {{ $t('orders.title') }} {{ $t('members.memberImages') }} {{ $t('members.notes') }} {{ $t('members.exercises') }} @@ -440,6 +441,7 @@ {{ clickTtPendingMemberIds.includes(member.id) ? '⏳' : '🏓' }} 🪶 + 📦 📝 🏃 + + @@ -553,6 +563,7 @@ import MemberNotesDialog from '../components/MemberNotesDialog.vue'; import MemberActivitiesDialog from '../components/MemberActivitiesDialog.vue'; import MemberTransferDialog from '../components/MemberTransferDialog.vue'; import MemberTtrHistoryDialog from '../components/MemberTtrHistoryDialog.vue'; +import MemberOrdersDialog from '../components/MemberOrdersDialog.vue'; import MembersOverviewSection from '../components/members/MembersOverviewSection.vue'; import { debounce } from '../utils/debounce.js'; import { buildInfoConfig, buildConfirmConfig, safeErrorMessage, sanitizeText } from '../utils/dialogUtils.js'; @@ -567,6 +578,7 @@ export default { MemberActivitiesDialog, MemberTransferDialog, MemberTtrHistoryDialog, + MemberOrdersDialog, MembersOverviewSection }, computed: { @@ -903,8 +915,10 @@ export default { showMemberInfo: false, showActivitiesModal: false, showMemberTtrHistoryDialog: false, + showMemberOrdersDialog: false, selectedMemberForActivities: null, selectedMemberForTtrHistory: null, + selectedMemberForOrders: null, memberTrainingGroups: [], trainingGroups: [], selectedGroupToAdd: '', @@ -2279,6 +2293,17 @@ export default { this.showMemberTtrHistoryDialog = false; this.selectedMemberForTtrHistory = null; }, + openOrdersDialog(member) { + if (!member) { + return; + } + this.selectedMemberForOrders = member; + this.showMemberOrdersDialog = true; + }, + closeOrdersDialog() { + this.showMemberOrdersDialog = false; + this.selectedMemberForOrders = null; + }, async updateRatingsFromMyTischtennis() { this.isUpdatingRatings = true; try { diff --git a/frontend/src/views/OrdersView.vue b/frontend/src/views/OrdersView.vue new file mode 100644 index 00000000..e6579da3 --- /dev/null +++ b/frontend/src/views/OrdersView.vue @@ -0,0 +1,35 @@ + + + + + {{ $t('orders.globalTitle') }} + {{ $t('orders.globalSubtitle') }} + + + + + + + + +
{{ $t('orders.globalSubtitle') }}