Add Falukant region and transport management features

- Implemented new endpoints in AdminController for managing Falukant regions, including fetching, updating, and deleting region distances.
- Enhanced the FalukantService with methods for retrieving region distances and handling upsert operations.
- Updated the router to expose new routes for region management and transport creation.
- Introduced a transport management interface in the frontend, allowing users to create and manage transports between branches.
- Added localization for new transport-related terms and improved the vehicle management interface to include transport options.
- Enhanced the database initialization logic to support new region and transport models.
This commit is contained in:
Torsten Schulz (local)
2025-11-26 16:44:27 +01:00
parent 29dd7ec80c
commit 06ea259dc9
27 changed files with 2100 additions and 57 deletions

View File

@@ -1,6 +1,6 @@
<template>
<div class="sale-section">
<!-- Beispielhafte Inventar-Tabelle -->
<!-- Inventar-Tabelle -->
<div v-if="inventory.length > 0" class="inventory-table">
<table>
<thead>
@@ -30,6 +30,121 @@
<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 }})
</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>
</tr>
</thead>
<tbody>
<tr v-for="t in runningTransports" :key="t.id">
<td>
{{ t.direction === 'outgoing'
? $t('falukant.branch.sale.runningDirectionOut')
: $t('falukant.branch.sale.runningDirectionIn') }}
</td>
<td>{{ $t(`falukant.product.${t.product.labelTr}`) }}</td>
<td>{{ t.size }}</td>
<td>{{ t.sourceRegion?.name }}</td>
<td>{{ t.targetRegion?.name }}</td>
<td>{{ formatEta(t) }}</td>
<td>{{ formatRemaining(t) }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
@@ -37,14 +152,46 @@
import apiClient from '@/utils/axios.js';
export default {
name: "SaleSection",
props: { branchId: { type: Number, required: true } },
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,
maxQuantity: 0,
distance: null,
durationHours: null,
eta: null,
durationLabel: '',
etaLabel: '',
routeNames: [],
cost: null,
costLabel: '',
},
runningTransports: [],
nowTs: Date.now(),
_transportTimer: null,
};
},
async mounted() {
await this.loadInventory();
await this.loadTransports();
this._transportTimer = setInterval(() => {
this.nowTs = Date.now();
}, 1000);
},
beforeUnmount() {
if (this._transportTimer) {
clearInterval(this._transportTimer);
this._transportTimer = null;
}
},
methods: {
async loadInventory() {
@@ -76,6 +223,205 @@
alert(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 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>
@@ -96,5 +442,11 @@
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 */
}
</style>