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

661 lines
24 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" :disabled="sellingItemIndex === index" />
<button @click="sellItem(index)" :disabled="sellingItemIndex === index || sellingAll">
{{ sellingItemIndex === index ? $t('falukant.branch.sale.selling') : $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" :disabled="sellingAll || sellingItemIndex !== null">
{{ sellingAll ? $t('falukant.branch.sale.selling') : $t('falukant.branch.sale.sellAllButton') }}
</button>
<div v-if="sellAllStatus" class="sell-all-status" :class="sellAllStatus.type">
{{ sellAllStatus.message }}
</div>
</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>
<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>
</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>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import apiClient from '@/utils/axios.js';
export default {
name: "SaleSection",
props: {
branchId: { type: Number, required: true },
vehicles: { type: Array, default: () => [] },
branches: { type: Array, default: () => [] },
},
data() {
return {
inventory: [],
sellingItemIndex: null,
sellingAll: false,
sellAllStatus: null,
transportForm: {
sourceKey: null,
vehicleTypeId: null,
targetBranchId: null,
quantity: 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,
transports: [],
});
}
const group = groups.get(key);
group.vehicleCount += 1;
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) {
// Muss in methods liegen (Vue3): in computed wäre es ein Getter und keine aufrufbare Funktion.
const key = value == null ? 'unknown' : String(value);
const tKey = `falukant.branch.transport.speed.${key}`;
const translated = this.$t(tKey);
if (!translated || translated === tKey) return value;
return translated;
},
async loadInventory() {
try {
const response = await apiClient.get(`/api/falukant/inventory/${this.branchId}`);
this.inventory = response.data.map(item => ({
...item,
sellQuantity: item.totalQuantity,
// Vue3: besserPrices direkt als Property setzen (statt this.$set)
betterPrices: Array.isArray(item.betterPrices) ? item.betterPrices : [],
}));
await this.loadPricesForInventory();
} catch (error) {
console.error('Error loading inventory:', error);
}
},
async loadPricesForInventory() {
for (const item of this.inventory) {
const itemKey = `${item.region.id}-${item.product.id}-${item.quality}`;
if (this.loadingPrices.has(itemKey)) continue;
this.loadingPrices.add(itemKey);
try {
// Aktueller Preis basierend auf sellCost
const currentPrice = item.product.sellCost || 0;
const { data } = await apiClient.get('/api/falukant/products/prices-in-cities', {
params: {
productId: item.product.id,
currentPrice: currentPrice
}
});
// Vue3: direkte Zuweisung ist reaktiv
item.betterPrices = Array.isArray(data) ? data : [];
} catch (error) {
console.error(`Error loading prices for item ${itemKey}:`, error);
item.betterPrices = [];
} finally {
this.loadingPrices.delete(itemKey);
}
}
},
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);
},
async sellItem(index) {
if (this.sellingItemIndex !== null || this.sellingAll) return;
const item = this.inventory[index];
const quantityToSell = item.sellQuantity || item.totalQuantity;
this.sellingItemIndex = index;
try {
await apiClient.post(`/api/falukant/sell`, {
branchId: this.branchId,
productId: item.product.id,
quantity: quantityToSell,
quality: item.quality,
});
// UI sofort freigeben (Label/Disabled zurücksetzen), dann Inventory refreshen
this.sellingItemIndex = null;
await this.loadInventory();
} catch (error) {
alert(this.$t('falukant.branch.sale.sellError'));
} finally {
this.sellingItemIndex = null;
}
},
async sellAll() {
if (this.sellingAll || this.sellingItemIndex !== null) return;
this.sellingAll = true;
this.sellAllStatus = null;
try {
const response = await apiClient.post(`/api/falukant/sell/all`, { branchId: this.branchId });
const revenue = response.data?.revenue || 0;
// UI sofort freigeben + Status setzen, danach Inventory refreshen
this.sellingAll = false;
this.sellAllStatus = {
type: 'success',
message: this.$t('falukant.branch.sale.sellAllSuccess', { revenue: this.formatMoney(revenue) })
};
// Inventory neu laden nach erfolgreichem Verkauf
await this.loadInventory();
} catch (error) {
// UI sofort freigeben + Fehlerstatus setzen
this.sellingAll = false;
this.sellAllStatus = {
type: 'error',
message: this.$t('falukant.branch.sale.sellAllError')
};
} finally {
// Falls noch nicht freigegeben (z.B. wenn ein unerwarteter Fehler vor Response passiert)
this.sellingAll = false;
// Status nach 5 Sekunden löschen
setTimeout(() => {
this.sellAllStatus = null;
}, 5000);
}
},
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 cost = Math.max(0.1, totalValue * 0.01);
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,
});
await this.loadInventory();
await this.loadTransports();
alert(this.$t('falukant.branch.sale.transportStarted'));
this.$emit('transportCreated');
} catch (error) {
console.error('Error creating transport:', error);
alert(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;
}
.sell-all-status {
margin-top: 10px;
padding: 8px;
border-radius: 4px;
}
.sell-all-status.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.sell-all-status.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
</style>