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

View File

@@ -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.

View File

@@ -22,6 +22,10 @@
<span class="dropdown-icon">🏓</span>
{{ $t('navigation.clickTtAccount') }}
</button>
<router-link to="/orders" class="dropdown-item" @click="userDropdownOpen = false">
<span class="dropdown-icon">📦</span>
{{ $t('navigation.orders') }}
</router-link>
<button v-if="canManagePermissions" type="button" class="dropdown-item" @click="openUserMenuDialog('PermissionsView', $t('navigation.permissions'))">
<span class="dropdown-icon">🔐</span>
{{ $t('navigation.permissions') }}

View File

@@ -80,6 +80,7 @@
>
📄
</span>
<span class="pointer" :title="$t('orders.title')" @click.stop="$emit('open-orders', member)">📦</span>
<span class="pointer" @click="$emit('open-tags', member)"></span>
</div>
</li>
@@ -136,6 +137,7 @@ export default {
'open-notes',
'show-pic',
'mark-form',
'open-orders',
'open-tags',
'open-quick-add',
'open-gallery'

View File

@@ -0,0 +1,56 @@
<template>
<BaseDialog
:model-value="modelValue"
:title="dialogTitle"
size="large"
:width="1220"
max-width="96vw"
@update:model-value="$emit('update:modelValue', $event)"
@close="$emit('close')"
>
<OrdersPanel :club-id="clubId" :member="member" />
<template #footer>
<button type="button" class="cancel-action" @click="$emit('update:modelValue', false)">
{{ $t('common.close') }}
</button>
</template>
</BaseDialog>
</template>
<script>
import BaseDialog from './BaseDialog.vue';
import OrdersPanel from './OrdersPanel.vue';
export default {
name: 'MemberOrdersDialog',
components: {
BaseDialog,
OrdersPanel
},
props: {
modelValue: {
type: Boolean,
required: true
},
clubId: {
type: [String, Number],
required: true
},
member: {
type: Object,
default: null
}
},
emits: ['update:modelValue', 'close'],
computed: {
dialogTitle() {
if (!this.member) {
return this.$t('orders.title');
}
return this.$t('orders.memberTitle', {
name: [this.member.firstName, this.member.lastName].filter(Boolean).join(' ')
});
}
}
};
</script>

View File

@@ -0,0 +1,503 @@
<template>
<div class="orders-panel">
<div class="orders-toolbar" :class="{ 'orders-toolbar-global': globalMode }">
<div v-if="globalMode" class="orders-filters">
<input
v-model="searchQuery"
type="search"
class="orders-search"
:placeholder="$t('orders.searchPlaceholder')"
>
<select v-model="statusFilter">
<option value="">{{ $t('orders.filterAllStatuses') }}</option>
<option v-for="status in statusOptions" :key="status.value" :value="status.value">
{{ status.label }}
</option>
</select>
<select v-model="clubFilter">
<option value="">{{ $t('orders.filterAllClubs') }}</option>
<option v-for="club in availableClubs" :key="club.id" :value="String(club.id)">
{{ club.name }}
</option>
</select>
</div>
<button type="button" class="btn-secondary" :disabled="loading" @click="loadOrders">
{{ $t('common.refresh') }}
</button>
</div>
<div v-if="!globalMode" class="order-create-card">
<div class="order-create-grid">
<label>
<span>{{ $t('orders.item') }}</span>
<input v-model.trim="newOrder.item" type="text" :placeholder="$t('orders.itemPlaceholder')">
</label>
<label>
<span>{{ $t('orders.cost') }}</span>
<input v-model="newOrder.cost" type="number" min="0" step="0.01">
</label>
<label>
<span>{{ $t('orders.paid') }}</span>
<input v-model="newOrder.paidAmount" type="number" min="0" step="0.01">
</label>
<label>
<span>{{ $t('orders.status') }}</span>
<select v-model="newOrder.status">
<option v-for="status in statusOptions" :key="status.value" :value="status.value">
{{ status.label }}
</option>
</select>
</label>
</div>
<div class="order-create-actions">
<button type="button" class="btn-primary" :disabled="creatingOrder || !newOrder.item" @click="createOrder">
{{ $t('orders.addOrder') }}
</button>
<span class="orders-create-hint">{{ $t('orders.dateAutoHint') }}</span>
</div>
</div>
<div v-if="loading" class="orders-state orders-state-info">{{ $t('orders.loading') }}</div>
<div v-else-if="error" class="orders-state orders-state-error">{{ error }}</div>
<div v-else-if="filteredOrders.length === 0" class="orders-state orders-state-empty">
{{ globalMode ? $t('orders.noOrdersGlobal') : $t('orders.noOrdersMember') }}
</div>
<div v-else class="orders-table-wrap">
<table class="orders-table">
<thead>
<tr>
<th v-if="globalMode">{{ $t('orders.club') }}</th>
<th v-if="globalMode">{{ $t('orders.member') }}</th>
<th>{{ $t('orders.item') }}</th>
<th>{{ $t('orders.status') }}</th>
<th>{{ $t('orders.orderDate') }}</th>
<th>{{ $t('orders.statusDate') }}</th>
<th>{{ $t('orders.cost') }}</th>
<th>{{ $t('orders.paid') }}</th>
<th>{{ $t('orders.open') }}</th>
<th>{{ $t('orders.history') }}</th>
<th>{{ $t('common.save') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="order in filteredOrders" :key="order.id">
<td v-if="globalMode">{{ order.club?.name || '' }}</td>
<td v-if="globalMode">{{ formatMemberName(order.member) }}</td>
<td>
<input v-model.trim="order.draftItem" type="text" class="orders-inline-input">
</td>
<td>
<select v-model="order.draftStatus">
<option v-for="status in statusOptions" :key="status.value" :value="status.value">
{{ status.label }}
</option>
</select>
</td>
<td>{{ formatDateTime(order.orderDate || order.createdAt) }}</td>
<td>{{ formatDateTime(order.statusDate || order.updatedAt) }}</td>
<td>
<input v-model="order.draftCost" type="number" min="0" step="0.01" class="orders-inline-input orders-inline-input-number">
</td>
<td>
<input v-model="order.draftPaidAmount" type="number" min="0" step="0.01" class="orders-inline-input orders-inline-input-number">
</td>
<td>{{ formatCurrency(calculateOpenAmount(order)) }}</td>
<td>
<details class="orders-history-details">
<summary>{{ order.historyEntries?.length || 0 }}</summary>
<div class="orders-history-list">
<div v-for="entry in order.historyEntries || []" :key="entry.id" class="orders-history-entry">
<strong>{{ statusLabel(entry.status) }}</strong>
<span>{{ formatDateTime(entry.changedAt) }}</span>
<span>{{ formatCurrency(entry.cost) }} / {{ formatCurrency(entry.paidAmount) }}</span>
</div>
</div>
</details>
</td>
<td>
<button
type="button"
class="btn-primary btn-small"
:disabled="savingOrderIds.includes(order.id) || !hasOrderChanges(order)"
@click="saveOrder(order)"
>
{{ savingOrderIds.includes(order.id) ? $t('common.loading') : $t('common.save') }}
</button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import apiClient from '../apiClient.js';
import { getSafeErrorMessage } from '../utils/errorMessages.js';
const STATUS_OPTIONS = [
{ value: 'requested', labelKey: 'orders.statusRequested' },
{ value: 'ordered', labelKey: 'orders.statusOrdered' },
{ value: 'arrived', labelKey: 'orders.statusArrived' },
{ value: 'handed_over', labelKey: 'orders.statusHandedOver' }
];
const normalizeAmount = (value) => {
const parsed = Number.parseFloat(value ?? 0);
if (!Number.isFinite(parsed)) {
return 0;
}
return Math.max(0, Number(parsed.toFixed(2)));
};
export default {
name: 'OrdersPanel',
props: {
clubId: {
type: [String, Number],
default: null
},
member: {
type: Object,
default: null
},
globalMode: {
type: Boolean,
default: false
}
},
data() {
return {
orders: [],
loading: false,
error: '',
creatingOrder: false,
savingOrderIds: [],
searchQuery: '',
statusFilter: '',
clubFilter: '',
newOrder: {
item: '',
status: 'requested',
cost: '',
paidAmount: ''
}
};
},
computed: {
statusOptions() {
return STATUS_OPTIONS.map((status) => ({
...status,
label: this.$t(status.labelKey)
}));
},
availableClubs() {
const map = new Map();
this.orders.forEach((order) => {
if (order.club?.id != null) {
map.set(String(order.club.id), { id: String(order.club.id), name: order.club.name });
}
});
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'de-DE'));
},
filteredOrders() {
return this.orders.filter((order) => {
if (this.statusFilter && order.status !== this.statusFilter) {
return false;
}
if (this.clubFilter && String(order.clubId) !== String(this.clubFilter)) {
return false;
}
if (!this.searchQuery.trim()) {
return true;
}
const search = this.searchQuery.trim().toLowerCase();
const haystack = [
order.item,
order.club?.name,
order.member?.firstName,
order.member?.lastName
].filter(Boolean).join(' ').toLowerCase();
return haystack.includes(search);
});
}
},
watch: {
clubId() {
if (!this.globalMode && this.member?.id) {
this.loadOrders();
}
},
member: {
deep: false,
handler() {
if (!this.globalMode && this.member?.id) {
this.loadOrders();
}
}
}
},
mounted() {
this.loadOrders();
},
methods: {
hydrateOrder(order) {
return {
...order,
cost: normalizeAmount(order.cost),
paidAmount: normalizeAmount(order.paidAmount),
openAmount: normalizeAmount(order.openAmount),
draftItem: order.item || '',
draftStatus: order.status || 'requested',
draftCost: String(normalizeAmount(order.cost)),
draftPaidAmount: String(normalizeAmount(order.paidAmount))
};
},
async loadOrders() {
if (!this.globalMode && (!this.clubId || !this.member?.id)) {
this.orders = [];
return;
}
this.loading = true;
this.error = '';
try {
const url = this.globalMode
? '/member-orders/global'
: `/member-orders/${this.clubId}/${this.member.id}`;
const response = await apiClient.get(url);
const orders = response.data?.orders || [];
this.orders = orders.map((order) => this.hydrateOrder(order));
} catch (error) {
this.error = getSafeErrorMessage(error, this.$t('orders.errorLoading'));
} finally {
this.loading = false;
}
},
async createOrder() {
if (!this.clubId || !this.member?.id || !this.newOrder.item.trim()) {
return;
}
this.creatingOrder = true;
this.error = '';
try {
const response = await apiClient.post(`/member-orders/${this.clubId}/${this.member.id}`, {
item: this.newOrder.item.trim(),
status: this.newOrder.status,
cost: normalizeAmount(this.newOrder.cost),
paidAmount: normalizeAmount(this.newOrder.paidAmount)
});
if (response.data?.order) {
this.orders.unshift(this.hydrateOrder(response.data.order));
this.newOrder = {
item: '',
status: 'requested',
cost: '',
paidAmount: ''
};
}
} catch (error) {
this.error = getSafeErrorMessage(error, this.$t('orders.errorSaving'));
} finally {
this.creatingOrder = false;
}
},
hasOrderChanges(order) {
return order.draftItem !== (order.item || '')
|| order.draftStatus !== order.status
|| normalizeAmount(order.draftCost) !== normalizeAmount(order.cost)
|| normalizeAmount(order.draftPaidAmount) !== normalizeAmount(order.paidAmount);
},
async saveOrder(order) {
this.savingOrderIds.push(order.id);
this.error = '';
try {
const response = await apiClient.patch(`/member-orders/${order.clubId}/${order.memberId}/${order.id}`, {
item: order.draftItem,
status: order.draftStatus,
cost: normalizeAmount(order.draftCost),
paidAmount: normalizeAmount(order.draftPaidAmount)
});
if (response.data?.order) {
const updated = this.hydrateOrder(response.data.order);
this.orders = this.orders.map((entry) => entry.id === order.id ? updated : entry);
}
} catch (error) {
this.error = getSafeErrorMessage(error, this.$t('orders.errorSaving'));
} finally {
this.savingOrderIds = this.savingOrderIds.filter((id) => id !== order.id);
}
},
formatCurrency(value) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(normalizeAmount(value));
},
formatDateTime(value) {
if (!value) {
return '';
}
return new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(value));
},
formatMemberName(member) {
if (!member) {
return '';
}
return [member.firstName, member.lastName].filter(Boolean).join(' ');
},
calculateOpenAmount(order) {
return Math.max(0, normalizeAmount(order.draftCost) - normalizeAmount(order.draftPaidAmount));
},
statusLabel(status) {
return this.$t(STATUS_OPTIONS.find((entry) => entry.value === status)?.labelKey || 'orders.statusRequested');
}
}
};
</script>
<style scoped>
.orders-panel {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 0;
}
.orders-toolbar,
.orders-filters,
.order-create-actions {
display: flex;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.orders-filters {
justify-content: flex-start;
flex: 1;
}
.orders-search {
min-width: 220px;
flex: 1 1 240px;
}
.order-create-card,
.orders-state {
border: 1px solid var(--border-color);
border-radius: 14px;
padding: 1rem;
background: var(--surface-muted);
}
.order-create-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.85rem;
}
.order-create-grid label,
.orders-table td,
.orders-table th {
min-width: 0;
}
.order-create-grid label span {
display: block;
margin-bottom: 0.35rem;
font-size: 0.85rem;
font-weight: 700;
}
.orders-create-hint {
color: var(--text-light);
font-size: 0.9rem;
}
.orders-table-wrap {
overflow: auto;
border: 1px solid var(--border-color);
border-radius: 14px;
background: var(--surface-color);
}
.orders-table {
width: 100%;
min-width: 980px;
border-collapse: collapse;
}
.orders-table th,
.orders-table td {
padding: 0.75rem;
vertical-align: top;
white-space: nowrap;
}
.orders-inline-input {
width: 100%;
min-width: 9rem;
}
.orders-inline-input-number {
min-width: 6.5rem;
}
.orders-history-details summary {
cursor: pointer;
list-style: none;
}
.orders-history-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 0.5rem;
min-width: 16rem;
}
.orders-history-entry {
display: flex;
flex-direction: column;
gap: 0.2rem;
font-size: 0.85rem;
white-space: normal;
}
.orders-state-info {
color: var(--primary-strong);
}
.orders-state-error {
color: #9b2c2c;
background: #fff5f5;
border-color: rgba(155, 44, 44, 0.2);
}
.orders-state-empty {
color: var(--text-light);
}
.btn-small {
padding: 0.45rem 0.7rem;
}
@media (max-width: 768px) {
.orders-search,
.orders-filters select,
.order-create-grid,
.orders-toolbar button,
.order-create-actions button {
width: 100%;
}
.orders-toolbar-global {
align-items: stretch;
}
}
</style>

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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": "日付",

View File

@@ -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",

View File

@@ -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": "วันที่",

View File

@@ -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 ibat 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",

View File

@@ -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": "日期",

View File

@@ -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 } },
];

View File

@@ -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"
/>
<MemberOrdersDialog
v-if="showMemberOrdersDialog && selectedMemberForOrders"
v-model="showMemberOrdersDialog"
:member="selectedMemberForOrders"
:club-id="currentClub"
@close="closeOrdersDialog"
/>
</div>
<!-- Info Dialog -->
@@ -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;

View File

@@ -74,6 +74,7 @@
</div>
<div class="member-preview-actions">
<button type="button" class="btn-primary" @click="editMember(selectedMemberPreview)">{{ $t('members.editMember') }}</button>
<button type="button" @click="openOrdersDialog(selectedMemberPreview)">{{ $t('orders.title') }}</button>
<button type="button" @click="openImageModal(selectedMemberPreview)">{{ $t('members.memberImages') }}</button>
<button type="button" @click="openNotesModal(selectedMemberPreview)">{{ $t('members.notes') }}</button>
<button type="button" @click="openActivitiesModal(selectedMemberPreview)">{{ $t('members.exercises') }}</button>
@@ -440,6 +441,7 @@
{{ clickTtPendingMemberIds.includes(member.id) ? '⏳' : '🏓' }}
</button>
<button type="button" class="member-icon-button member-icon-button-edit" :title="$t('members.editMember')" @click.stop="editMember(member)">🪶</button>
<button type="button" class="member-icon-button" :title="$t('orders.title')" @click.stop="openOrdersDialog(member)">📦</button>
<button type="button" class="member-icon-button" :title="$t('members.notes')" @click.stop="openNotesModal(member)">📝</button>
<button type="button" class="member-icon-button" :title="$t('members.exercises')" @click.stop="openActivitiesModal(member)">🏃</button>
<button
@@ -536,6 +538,14 @@
:club-id="currentClub"
@close="closeTtrHistoryDialog"
/>
<MemberOrdersDialog
v-if="showMemberOrdersDialog && selectedMemberForOrders"
v-model="showMemberOrdersDialog"
:member="selectedMemberForOrders"
:club-id="currentClub"
@close="closeOrdersDialog"
/>
</div>
</template>
@@ -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 {

View File

@@ -0,0 +1,35 @@
<template>
<div class="orders-view">
<div class="page-header">
<div>
<h2>{{ $t('orders.globalTitle') }}</h2>
<p class="orders-view-subtitle">{{ $t('orders.globalSubtitle') }}</p>
</div>
</div>
<OrdersPanel global-mode />
</div>
</template>
<script>
import OrdersPanel from '../components/OrdersPanel.vue';
export default {
name: 'OrdersView',
components: {
OrdersPanel
}
};
</script>
<style scoped>
.orders-view {
display: flex;
flex-direction: column;
gap: 1rem;
}
.orders-view-subtitle {
margin: 0.35rem 0 0;
color: var(--text-light);
}
</style>