Files
yourpart3/frontend/src/components/falukant/SaleSection.vue

619 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="sale-section">
<!-- Inventar-Tabelle -->
<div v-if="inventory.length > 0" class="inventory-table">
<table>
<thead>
<tr>
<th>{{ $t('falukant.branch.sale.region') }}</th>
<th>{{ $t('falukant.branch.sale.product') }}</th>
<th>{{ $t('falukant.branch.sale.quality') }}</th>
<th>{{ $t('falukant.branch.sale.quantity') }}</th>
<th>{{ $t('falukant.branch.sale.sell') }}</th>
<th>Bessere Preise</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in inventory" :key="`${item.region.id}-${item.product.id}-${item.quality}`">
<td>{{ item.region.name }}</td>
<td>{{ $t(`falukant.product.${item.product.labelTr}`) }}</td>
<td>{{ item.quality }}</td>
<td>{{ item.totalQuantity }}</td>
<td>
<input type="number" v-model.number="item.sellQuantity" :min="1" :max="item.totalQuantity" />
<button @click="sellItem(index)">{{ $t('falukant.branch.sale.sellButton') }}</button>
</td>
<td>
<div v-if="item.betterPrices && item.betterPrices.length > 0" class="price-cities">
<span v-for="city in item.betterPrices" :key="city.regionId"
:class="['city-price', getCityPriceClass(city.branchType)]"
:title="`${city.regionName}: ${formatPrice(city.price)}`">
{{ city.regionName }}
</span>
</div>
<span v-else class="no-better-prices"></span>
</td>
</tr>
</tbody>
</table>
<button @click="sellAll">{{ $t('falukant.branch.sale.sellAllButton') }}</button>
</div>
<div v-else>
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
</div>
<!-- Transport anlegen (nur wenn Inventar vorhanden) -->
<div class="transport-form" v-if="inventory.length > 0">
<h4>{{ $t('falukant.branch.sale.transportTitle') }}</h4>
<div class="transport-row">
<label>
{{ $t('falukant.branch.sale.transportSource') }}
<select v-model.number="transportForm.sourceKey" @change="() => { recalcMaxQuantity(); loadRouteInfo(); }">
<option :value="null" disabled>{{ $t('falukant.branch.sale.transportSourcePlaceholder') }}</option>
<option v-for="opt in inventoryOptions()" :key="opt.key" :value="opt.key">
{{ opt.label }}
</option>
</select>
</label>
<label>
{{ $t('falukant.branch.sale.transportVehicle') }}
<select v-model.number="transportForm.vehicleTypeId" @change="() => { recalcMaxQuantity(); loadRouteInfo(); }">
<option :value="null" disabled>{{ $t('falukant.branch.sale.transportVehiclePlaceholder') }}</option>
<option v-for="vt in vehicleTypeOptions()" :key="vt.id" :value="vt.id">
{{ $t(`falukant.branch.vehicles.${vt.tr}`) }} ({{ vt.count }} × {{ vt.capacity }}) - {{ speedLabel(vt.speed) }}
</option>
</select>
</label>
<label>
{{ $t('falukant.branch.sale.transportTarget') }}
<select v-model.number="transportForm.targetBranchId" @change="loadRouteInfo">
<option :value="null" disabled>{{ $t('falukant.branch.sale.transportTargetPlaceholder') }}</option>
<option v-for="tb in targetBranchOptions()" :key="tb.id" :value="tb.id">
{{ tb.label }}
</option>
</select>
</label>
<label>
{{ $t('falukant.branch.sale.transportQuantity') }}
<input
type="number"
v-model.number="transportForm.quantity"
:min="1"
:max="transportForm.maxQuantity || 0"
@input="recalcCost"
/>
<span v-if="transportForm.maxQuantity">
({{ $t('falukant.branch.sale.transportMax', { max: transportForm.maxQuantity }) }})
</span>
</label>
<label>
{{ $t('falukant.branch.transport.guardCount') }}
<input
type="number"
v-model.number="transportForm.guardCount"
min="0"
max="20"
@input="recalcCost"
/>
</label>
<div v-if="transportForm.costLabel">
{{ $t('falukant.branch.sale.transportCost', { cost: transportForm.costLabel }) }}
</div>
<button
@click="createTransport"
:disabled="
transportForm.sourceKey === null ||
transportForm.sourceKey === undefined ||
!transportForm.vehicleTypeId ||
!transportForm.targetBranchId ||
!transportForm.maxQuantity ||
!transportForm.quantity
"
>
{{ $t('falukant.branch.sale.transportCreate') }}
</button>
</div>
<div class="transport-route" v-if="transportForm.durationLabel">
<div>
{{ $t('falukant.branch.sale.transportDuration', { duration: transportForm.durationLabel }) }}
</div>
<div>
{{ $t('falukant.branch.sale.transportArrival', { datetime: transportForm.etaLabel }) }}
</div>
<div v-if="transportForm.routeNames && transportForm.routeNames.length">
{{ $t('falukant.branch.sale.transportRoute') }}:
{{ transportForm.routeNames.join(' ') }}
</div>
</div>
</div>
<!-- Laufende Transporte (immer im Inventar-Tab sichtbar, auch ohne Inventar) -->
<div class="running-transports" v-if="runningTransports.length">
<h5>{{ $t('falukant.branch.sale.runningTransportsTitle') }}</h5>
<table>
<thead>
<tr>
<th>{{ $t('falukant.branch.sale.runningDirection') }}</th>
<th>{{ $t('falukant.branch.sale.runningProduct') }}</th>
<th>{{ $t('falukant.branch.sale.runningQuantity') }}</th>
<th>{{ $t('falukant.branch.sale.runningSource') }}</th>
<th>{{ $t('falukant.branch.sale.runningTarget') }}</th>
<th>{{ $t('falukant.branch.sale.runningEta') }}</th>
<th>{{ $t('falukant.branch.sale.runningRemaining') }}</th>
<th>{{ $t('falukant.branch.sale.runningVehicleCount') }}</th>
<th>{{ $t('falukant.branch.sale.runningGuards') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(group, index) in groupedTransports" :key="`group-${index}`">
<td>
{{ group.direction === 'outgoing'
? $t('falukant.branch.sale.runningDirectionOut')
: $t('falukant.branch.sale.runningDirectionIn') }}
</td>
<td>
<span v-if="group.product && group.product.labelTr">
{{ $t(`falukant.product.${group.product.labelTr}`) }}
</span>
<span v-else class="no-product">
{{ $t('falukant.branch.sale.runningNoProduct') }}
</span>
</td>
<td>
<span v-if="group.product && group.totalQuantity > 0">{{ group.totalQuantity }}</span>
<span v-else></span>
</td>
<td>{{ group.sourceRegion?.name }}</td>
<td>{{ group.targetRegion?.name }}</td>
<td>{{ formatEta({ eta: group.eta }) }}</td>
<td>{{ formatRemaining({ eta: group.eta }) }}</td>
<td>{{ group.vehicleCount }}</td>
<td>{{ group.totalGuards || 0 }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
import { showError, showSuccess } from '@/utils/feedback.js';
export default {
name: "SaleSection",
props: {
branchId: { type: Number, required: true },
vehicles: { type: Array, default: () => [] },
branches: { type: Array, default: () => [] },
},
data() {
return {
inventory: [],
transportForm: {
sourceKey: null,
vehicleTypeId: null,
targetBranchId: null,
quantity: 0,
guardCount: 0,
maxQuantity: 0,
distance: null,
durationHours: null,
eta: null,
durationLabel: '',
etaLabel: '',
routeNames: [],
cost: null,
costLabel: '',
},
runningTransports: [],
nowTs: Date.now(),
_transportTimer: null,
loadingPrices: new Set(),
};
},
computed: {
groupedTransports() {
const groups = new Map();
for (const transport of this.runningTransports) {
// Erstelle einen Schlüssel für die Gruppierung
// Wichtig: Restzeit nicht in den Schlüssel einbeziehen, da sie sich ständig ändert
const productId = transport.product?.id || null;
const productLabelTr = transport.product?.labelTr || null;
const sourceId = transport.sourceRegion?.id || null;
const targetId = transport.targetRegion?.id || null;
const direction = transport.direction;
// ETA als Zeitstempel für Gruppierung (auf Sekunden genau, um kleine Unterschiede zu ignorieren)
const eta = transport.eta ? Math.floor(new Date(transport.eta).getTime() / 1000) : null;
// Gruppierungsschlüssel: alle relevanten Eigenschaften außer Restzeit
const key = `${direction}-${productId}-${productLabelTr}-${sourceId}-${targetId}-${eta}`;
if (!groups.has(key)) {
groups.set(key, {
direction: transport.direction,
product: transport.product,
sourceRegion: transport.sourceRegion,
targetRegion: transport.targetRegion,
eta: transport.eta,
vehicleCount: 0,
totalQuantity: 0,
totalGuards: 0,
transports: [],
});
}
const group = groups.get(key);
group.vehicleCount += 1;
group.totalGuards += transport.guardCount || 0;
if (transport.product && transport.size > 0) {
group.totalQuantity += transport.size || 0;
}
group.transports.push(transport);
}
// Sortiere nach ETA (früheste zuerst)
return Array.from(groups.values()).sort((a, b) => {
if (!a.eta && !b.eta) return 0;
if (!a.eta) return 1;
if (!b.eta) return -1;
return new Date(a.eta).getTime() - new Date(b.eta).getTime();
});
},
},
async mounted() {
await this.loadInventory();
await this.loadTransports();
this._transportTimer = setInterval(() => {
this.nowTs = Date.now();
}, 1000);
await this.loadPricesForInventory();
},
beforeUnmount() {
if (this._transportTimer) {
clearInterval(this._transportTimer);
this._transportTimer = null;
}
},
methods: {
speedLabel(value) {
if (value == null) return this.$t('falukant.branch.transport.speed.unknown') || '—';
if (typeof value === 'object') {
const k = value.tr ?? value.id ?? 'unknown';
const tKey = `falukant.branch.transport.speed.${k}`;
const t = this.$t(tKey);
return (t && t !== tKey) ? t : String(k);
}
const key = String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
return (!translated || translated === tKey) ? key : translated;
},
async loadInventory() {
try {
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
this.inventory = response.data.map(item => ({
...item,
sellQuantity: item.totalQuantity,
}));
await this.loadPricesForInventory();
} catch (error) {
console.error('Error loading inventory:', error);
}
},
async loadPricesForInventory() {
if (this.inventory.length === 0) return;
const currentRegionId = this.inventory[0]?.region?.id ?? null;
const items = this.inventory.map(item => ({
productId: item.product.id,
currentPrice: item.product.sellCost || 0
}));
try {
const { data } = await apiClient.post('/api/falukant/products/prices-in-cities-batch', {
currentRegionId,
items
});
for (const item of this.inventory) {
item.betterPrices = data && data[item.product.id] ? data[item.product.id] : [];
}
} catch (error) {
console.error('Error loading prices for inventory:', error);
for (const item of this.inventory) {
item.betterPrices = [];
}
}
},
getCityPriceClass(branchType) {
if (branchType === 'store') return 'city-price-green';
if (branchType === 'production') return 'city-price-orange';
return 'city-price-red';
},
formatPrice(price) {
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(price);
},
sellItem(index) {
const item = this.inventory[index];
const quantityToSell = item.sellQuantity || item.totalQuantity;
apiClient.post(`/api/falukant/sell`, {
branchId: this.branchId,
productId: item.product.id,
quantity: quantityToSell,
quality: item.quality,
}).catch(() => {
showError(this, this.$t('falukant.branch.sale.sellError'));
});
},
sellAll() {
apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId })
.catch(() => {
showError(this, this.$t('falukant.branch.sale.sellAllError'));
});
},
inventoryOptions() {
return this.inventory.map((item, index) => ({
key: index,
label: `${this.$t(`falukant.product.${item.product.labelTr}`)} (Q${item.quality}, ${item.totalQuantity})`,
productId: item.product.id,
totalQuantity: item.totalQuantity,
}));
},
vehicleTypeOptions() {
const groups = {};
for (const v of this.vehicles) {
if (v.status !== 'available' || !v.type || !v.type.id) continue;
const id = v.type.id;
if (!groups[id]) {
groups[id] = {
id,
tr: v.type.tr,
capacity: v.type.capacity,
speed: v.type.speed,
count: 0,
};
}
groups[id].count += 1;
}
return Object.values(groups);
},
targetBranchOptions() {
return (this.branches || [])
.filter(b => ['store', 'fullstack'].includes(b.branchTypeLabelTr))
// aktuelle Niederlassung darf nicht als Ziel angeboten werden
.filter(b => b.id !== this.branchId)
.map(b => ({
id: b.id,
label: `${b.cityName} ${b.type}`,
}));
},
recalcMaxQuantity() {
const source = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey);
const vType = this.vehicleTypeOptions().find(v => v.id === this.transportForm.vehicleTypeId);
if (!source || !vType) {
this.transportForm.maxQuantity = 0;
this.transportForm.quantity = 0;
this.recalcCost();
return;
}
const maxByInventory = source.totalQuantity;
const maxByVehicles = vType.capacity * vType.count;
const max = Math.min(maxByInventory, maxByVehicles);
this.transportForm.maxQuantity = max;
if (!this.transportForm.quantity || this.transportForm.quantity > max) {
this.transportForm.quantity = max;
}
this.recalcCost();
},
recalcCost() {
const idx = this.transportForm.sourceKey;
if (idx === null || idx === undefined) {
this.transportForm.cost = null;
this.transportForm.costLabel = '';
return;
}
const item = this.inventory[idx];
const qty = this.transportForm.quantity || 0;
if (!item || !item.product || item.product.sellCost == null || qty <= 0) {
this.transportForm.cost = null;
this.transportForm.costLabel = '';
return;
}
const unitValue = item.product.sellCost || 0;
const totalValue = unitValue * qty;
const guardCost = (this.transportForm.guardCount || 0) * 4;
const cost = Math.max(0.1, totalValue * 0.01) + guardCost;
this.transportForm.cost = cost;
this.transportForm.costLabel = this.formatMoney(cost);
},
formatMoney(amount) {
if (amount == null) return '';
try {
return amount.toLocaleString(undefined, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
} catch (e) {
return String(amount);
}
},
async loadRouteInfo() {
this.transportForm.distance = null;
this.transportForm.durationHours = null;
this.transportForm.eta = null;
this.transportForm.durationLabel = '';
this.transportForm.etaLabel = '';
this.transportForm.routeNames = [];
const sourceOpt = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey);
const vType = this.vehicleTypeOptions().find(v => v.id === this.transportForm.vehicleTypeId);
const targetBranch = (this.branches || []).find(b => b.id === this.transportForm.targetBranchId);
if (!sourceOpt || !vType || !targetBranch) {
return;
}
const sourceBranch = (this.branches || []).find(b => b.id === this.branchId);
if (!sourceBranch) {
return;
}
try {
const { data } = await apiClient.get('/api/falukant/transports/route', {
params: {
sourceRegionId: sourceBranch.regionId,
targetRegionId: targetBranch.regionId,
vehicleTypeId: vType.id,
},
});
if (data && data.totalDistance != null) {
const distance = data.totalDistance;
const speed = vType.speed || 1;
const hours = distance / speed;
this.transportForm.distance = distance;
this.transportForm.durationHours = hours;
const now = new Date();
const etaMs = now.getTime() + hours * 60 * 60 * 1000;
const etaDate = new Date(etaMs);
const fullHours = Math.floor(hours);
const minutes = Math.round((hours - fullHours) * 60);
const parts = [];
if (fullHours > 0) parts.push(`${fullHours} h`);
if (minutes > 0) parts.push(`${minutes} min`);
this.transportForm.durationLabel = parts.length ? parts.join(' ') : '0 min';
this.transportForm.etaLabel = etaDate.toLocaleString();
this.transportForm.routeNames = (data.regions || []).map(r => r.name);
}
} catch (error) {
console.error('Error loading transport route:', error);
this.transportForm.distance = null;
this.transportForm.durationHours = null;
this.transportForm.eta = null;
this.transportForm.durationLabel = '';
this.transportForm.etaLabel = '';
this.transportForm.routeNames = [];
}
},
async createTransport() {
const source = this.inventoryOptions().find(o => o.key === this.transportForm.sourceKey);
if (!source) return;
try {
await apiClient.post('/api/falukant/transports', {
branchId: this.branchId,
vehicleTypeId: this.transportForm.vehicleTypeId,
productId: source.productId,
quantity: this.transportForm.quantity,
targetBranchId: this.transportForm.targetBranchId,
guardCount: this.transportForm.guardCount || 0,
});
await this.loadInventory();
await this.loadTransports();
showSuccess(this, this.$t('falukant.branch.sale.transportStarted'));
this.$emit('transportCreated');
} catch (error) {
console.error('Error creating transport:', error);
showError(this, this.$t('falukant.branch.sale.transportError'));
}
},
async loadTransports() {
try {
const { data } = await apiClient.get(`/api/falukant/transports/branch/${this.branchId}`);
this.runningTransports = Array.isArray(data) ? data : [];
} catch (error) {
console.error('Error loading transports:', error);
this.runningTransports = [];
}
},
formatEta(transport) {
if (!transport || !transport.eta) return '';
const etaDate = new Date(transport.eta);
if (Number.isNaN(etaDate.getTime())) return '';
return etaDate.toLocaleString();
},
formatRemaining(transport) {
if (!transport || !transport.eta) return '';
const etaMs = new Date(transport.eta).getTime();
if (Number.isNaN(etaMs)) return '';
let diff = Math.floor((etaMs - this.nowTs) / 1000);
if (diff <= 0) {
return this.$t('falukant.branch.production.noProductions') ? '0s' : '0s';
}
const hours = Math.floor(diff / 3600);
diff %= 3600;
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
const parts = [];
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0 || hours > 0) parts.push(`${minutes}m`);
parts.push(`${seconds}s`);
return parts.join(' ');
},
},
};
</script>
<style scoped>
.sale-section {
border: 1px solid #ccc;
margin: 10px 0;
border-radius: 4px;
padding: 10px;
}
.inventory-table table {
width: 100%;
border-collapse: collapse;
}
.inventory-table th,
.inventory-table td {
padding: 2px 3px;
border: 1px solid #ddd;
}
/* Größerer Abstand zwischen den Spalten der Transport-Tabelle */
.running-transports table {
border-collapse: separate;
border-spacing: 16px 0; /* horizontaler Abstand zwischen Spalten */
}
.price-cities {
display: flex;
flex-wrap: wrap;
gap: 0.3em;
}
.city-price {
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.85em;
cursor: help;
}
.city-price-green {
background-color: #90EE90;
color: #000;
}
.city-price-orange {
background-color: #FFA500;
color: #000;
}
.city-price-red {
background-color: #FF6B6B;
color: #fff;
}
.no-better-prices {
color: #999;
font-style: italic;
}
</style>