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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user