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:
@@ -38,6 +38,11 @@ class AdminController {
|
|||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
this.getUserStatistics = this.getUserStatistics.bind(this);
|
this.getUserStatistics = this.getUserStatistics.bind(this);
|
||||||
|
this.getFalukantRegions = this.getFalukantRegions.bind(this);
|
||||||
|
this.updateFalukantRegionMap = this.updateFalukantRegionMap.bind(this);
|
||||||
|
this.getRegionDistances = this.getRegionDistances.bind(this);
|
||||||
|
this.upsertRegionDistance = this.upsertRegionDistance.bind(this);
|
||||||
|
this.deleteRegionDistance = this.deleteRegionDistance.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getOpenInterests(req, res) {
|
async getOpenInterests(req, res) {
|
||||||
@@ -315,6 +320,69 @@ class AdminController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFalukantRegions(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const regions = await AdminService.getFalukantRegions(userId);
|
||||||
|
res.status(200).json(regions);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFalukantRegionMap(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const { map } = req.body || {};
|
||||||
|
const region = await AdminService.updateFalukantRegionMap(userId, id, map);
|
||||||
|
res.status(200).json(region);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : (error.message === 'regionNotFound' ? 404 : 500);
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRegionDistances(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const distances = await AdminService.getRegionDistances(userId);
|
||||||
|
res.status(200).json(distances);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 500;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRegionDistance(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const record = await AdminService.upsertRegionDistance(userId, req.body || {});
|
||||||
|
res.status(200).json(record);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : 400;
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegionDistance(req, res) {
|
||||||
|
try {
|
||||||
|
const { userid: userId } = req.headers;
|
||||||
|
const { id } = req.params;
|
||||||
|
const result = await AdminService.deleteRegionDistance(userId, id);
|
||||||
|
res.status(200).json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(error);
|
||||||
|
const status = error.message === 'noaccess' ? 403 : (error.message === 'notfound' ? 404 : 500);
|
||||||
|
res.status(status).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getRoomTypes(req, res) {
|
async getRoomTypes(req, res) {
|
||||||
try {
|
try {
|
||||||
const userId = req.headers.userid;
|
const userId = req.headers.userid;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class FalukantController {
|
|||||||
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId));
|
this.createBranch = this._wrapWithUser((userId, req) => this.service.createBranch(userId, req.body.cityId, req.body.branchTypeId));
|
||||||
this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
|
this.getBranchTypes = this._wrapWithUser((userId) => this.service.getBranchTypes(userId));
|
||||||
this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch));
|
this.getBranch = this._wrapWithUser((userId, req) => this.service.getBranch(userId, req.params.branch));
|
||||||
|
this.upgradeBranch = this._wrapWithUser((userId, req) => this.service.upgradeBranch(userId, req.body.branchId));
|
||||||
this.createProduction = this._wrapWithUser((userId, req) => {
|
this.createProduction = this._wrapWithUser((userId, req) => {
|
||||||
const { branchId, productId, quantity } = req.body;
|
const { branchId, productId, quantity } = req.body;
|
||||||
return this.service.createProduction(userId, branchId, productId, quantity);
|
return this.service.createProduction(userId, branchId, productId, quantity);
|
||||||
@@ -186,6 +187,19 @@ class FalukantController {
|
|||||||
(userId, req) => this.service.buyVehicles(userId, req.body),
|
(userId, req) => this.service.buyVehicles(userId, req.body),
|
||||||
{ successStatus: 201 }
|
{ successStatus: 201 }
|
||||||
);
|
);
|
||||||
|
this.getVehicles = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.getVehicles(userId, req.query.regionId)
|
||||||
|
);
|
||||||
|
this.createTransport = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.createTransport(userId, req.body),
|
||||||
|
{ successStatus: 201 }
|
||||||
|
);
|
||||||
|
this.getTransportRoute = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.getTransportRoute(userId, req.query)
|
||||||
|
);
|
||||||
|
this.getBranchTransports = this._wrapWithUser(
|
||||||
|
(userId, req) => this.service.getBranchTransports(userId, req.params.branchId)
|
||||||
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -270,6 +270,10 @@ const menuStructure = {
|
|||||||
visible: ["mainadmin", "falukant"],
|
visible: ["mainadmin", "falukant"],
|
||||||
path: "/admin/falukant/database"
|
path: "/admin/falukant/database"
|
||||||
},
|
},
|
||||||
|
mapEditor: {
|
||||||
|
visible: ["mainadmin", "falukant"],
|
||||||
|
path: "/admin/falukant/map"
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
minigames: {
|
minigames: {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ import UndergroundType from './falukant/type/underground.js';
|
|||||||
import VehicleType from './falukant/type/vehicle.js';
|
import VehicleType from './falukant/type/vehicle.js';
|
||||||
import Vehicle from './falukant/data/vehicle.js';
|
import Vehicle from './falukant/data/vehicle.js';
|
||||||
import Transport from './falukant/data/transport.js';
|
import Transport from './falukant/data/transport.js';
|
||||||
|
import RegionDistance from './falukant/data/region_distance.js';
|
||||||
import Blog from './community/blog.js';
|
import Blog from './community/blog.js';
|
||||||
import BlogPost from './community/blog_post.js';
|
import BlogPost from './community/blog_post.js';
|
||||||
import Campaign from './match3/campaign.js';
|
import Campaign from './match3/campaign.js';
|
||||||
@@ -453,6 +454,24 @@ export default function setupAssociations() {
|
|||||||
as: 'region',
|
as: 'region',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Region distances
|
||||||
|
RegionData.hasMany(RegionDistance, {
|
||||||
|
foreignKey: 'sourceRegionId',
|
||||||
|
as: 'distancesFrom',
|
||||||
|
});
|
||||||
|
RegionData.hasMany(RegionDistance, {
|
||||||
|
foreignKey: 'targetRegionId',
|
||||||
|
as: 'distancesTo',
|
||||||
|
});
|
||||||
|
RegionDistance.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'sourceRegionId',
|
||||||
|
as: 'sourceRegion',
|
||||||
|
});
|
||||||
|
RegionDistance.belongsTo(RegionData, {
|
||||||
|
foreignKey: 'targetRegionId',
|
||||||
|
as: 'targetRegion',
|
||||||
|
});
|
||||||
|
|
||||||
Transport.belongsTo(RegionData, {
|
Transport.belongsTo(RegionData, {
|
||||||
foreignKey: 'sourceRegionId',
|
foreignKey: 'sourceRegionId',
|
||||||
as: 'sourceRegion',
|
as: 'sourceRegion',
|
||||||
|
|||||||
51
backend/models/falukant/data/region_distance.js
Normal file
51
backend/models/falukant/data/region_distance.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Model, DataTypes } from 'sequelize';
|
||||||
|
import { sequelize } from '../../../utils/sequelize.js';
|
||||||
|
import RegionData from './region.js';
|
||||||
|
|
||||||
|
class RegionDistance extends Model {}
|
||||||
|
|
||||||
|
RegionDistance.init(
|
||||||
|
{
|
||||||
|
sourceRegionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: RegionData,
|
||||||
|
key: 'id',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
targetRegionId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
references: {
|
||||||
|
model: RegionData,
|
||||||
|
key: 'id',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
transportMode: {
|
||||||
|
// e.g. 'land', 'water', 'air' – should match VehicleType.transportMode
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
distance: {
|
||||||
|
// distance between regions (e.g. in abstract units, used for travel time etc.)
|
||||||
|
type: DataTypes.DOUBLE,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sequelize,
|
||||||
|
modelName: 'RegionDistance',
|
||||||
|
tableName: 'region_distance',
|
||||||
|
schema: 'falukant_data',
|
||||||
|
timestamps: false,
|
||||||
|
underscored: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default RegionDistance;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -17,6 +17,18 @@ Vehicle.init(
|
|||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
condition: {
|
||||||
|
// current condition of the vehicle (0–100)
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 100,
|
||||||
|
},
|
||||||
|
availableFrom: {
|
||||||
|
// timestamp when the vehicle becomes available for use
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: DataTypes.NOW,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sequelize,
|
sequelize,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ VehicleType.init(
|
|||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
},
|
},
|
||||||
|
buildTimeMinutes: {
|
||||||
|
// time to construct the vehicle, in minutes
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
},
|
||||||
capacity: {
|
capacity: {
|
||||||
// transport capacity (e.g. in units of goods)
|
// transport capacity (e.g. in units of goods)
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ import Underground from './falukant/data/underground.js';
|
|||||||
import VehicleType from './falukant/type/vehicle.js';
|
import VehicleType from './falukant/type/vehicle.js';
|
||||||
import Vehicle from './falukant/data/vehicle.js';
|
import Vehicle from './falukant/data/vehicle.js';
|
||||||
import Transport from './falukant/data/transport.js';
|
import Transport from './falukant/data/transport.js';
|
||||||
|
import RegionDistance from './falukant/data/region_distance.js';
|
||||||
|
|
||||||
import Room from './chat/room.js';
|
import Room from './chat/room.js';
|
||||||
import ChatUser from './chat/user.js';
|
import ChatUser from './chat/user.js';
|
||||||
@@ -210,6 +211,7 @@ const models = {
|
|||||||
Credit,
|
Credit,
|
||||||
DebtorsPrism,
|
DebtorsPrism,
|
||||||
HealthActivity,
|
HealthActivity,
|
||||||
|
RegionDistance,
|
||||||
VehicleType,
|
VehicleType,
|
||||||
Vehicle,
|
Vehicle,
|
||||||
Transport,
|
Transport,
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ router.get('/falukant/branches/:falukantUserId', authenticate, adminController.g
|
|||||||
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
router.put('/falukant/stock/:stockId', authenticate, adminController.updateFalukantStock);
|
||||||
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
router.post('/falukant/stock', authenticate, adminController.addFalukantStock);
|
||||||
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
|
router.get('/falukant/stock-types', authenticate, adminController.getFalukantStockTypes);
|
||||||
|
router.get('/falukant/regions', authenticate, adminController.getFalukantRegions);
|
||||||
|
router.put('/falukant/regions/:id/map', authenticate, adminController.updateFalukantRegionMap);
|
||||||
|
router.get('/falukant/region-distances', authenticate, adminController.getRegionDistances);
|
||||||
|
router.post('/falukant/region-distances', authenticate, adminController.upsertRegionDistance);
|
||||||
|
router.delete('/falukant/region-distances/:id', authenticate, adminController.deleteRegionDistance);
|
||||||
|
|
||||||
// --- Minigames Admin ---
|
// --- Minigames Admin ---
|
||||||
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);
|
router.get('/minigames/match3/campaigns', authenticate, adminController.getMatch3Campaigns);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ router.get('/branches/types', falukantController.getBranchTypes);
|
|||||||
router.get('/branches/:branch', falukantController.getBranch);
|
router.get('/branches/:branch', falukantController.getBranch);
|
||||||
router.get('/branches', falukantController.getBranches);
|
router.get('/branches', falukantController.getBranches);
|
||||||
router.post('/branches', falukantController.createBranch);
|
router.post('/branches', falukantController.createBranch);
|
||||||
|
router.post('/branches/upgrade', falukantController.upgradeBranch);
|
||||||
router.get('/productions', falukantController.getAllProductions);
|
router.get('/productions', falukantController.getAllProductions);
|
||||||
router.post('/production', falukantController.createProduction);
|
router.post('/production', falukantController.createProduction);
|
||||||
router.get('/production/:branchId', falukantController.getProduction);
|
router.get('/production/:branchId', falukantController.getProduction);
|
||||||
@@ -71,6 +72,10 @@ router.post('/politics/open', falukantController.applyForElections);
|
|||||||
router.get('/cities', falukantController.getRegions);
|
router.get('/cities', falukantController.getRegions);
|
||||||
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
router.get('/vehicles/types', falukantController.getVehicleTypes);
|
||||||
router.post('/vehicles', falukantController.buyVehicles);
|
router.post('/vehicles', falukantController.buyVehicles);
|
||||||
|
router.get('/vehicles', falukantController.getVehicles);
|
||||||
|
router.post('/transports', falukantController.createTransport);
|
||||||
|
router.get('/transports/route', falukantController.getTransportRoute);
|
||||||
|
router.get('/transports/branch/:branchId', falukantController.getBranchTransports);
|
||||||
router.get('/underground/types', falukantController.getUndergroundTypes);
|
router.get('/underground/types', falukantController.getUndergroundTypes);
|
||||||
router.get('/notifications', falukantController.getNotifications);
|
router.get('/notifications', falukantController.getNotifications);
|
||||||
router.get('/notifications/all', falukantController.getAllNotifications);
|
router.get('/notifications/all', falukantController.getAllNotifications);
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ import Branch from "../models/falukant/data/branch.js";
|
|||||||
import FalukantStock from "../models/falukant/data/stock.js";
|
import FalukantStock from "../models/falukant/data/stock.js";
|
||||||
import FalukantStockType from "../models/falukant/type/stock.js";
|
import FalukantStockType from "../models/falukant/type/stock.js";
|
||||||
import RegionData from "../models/falukant/data/region.js";
|
import RegionData from "../models/falukant/data/region.js";
|
||||||
|
import RegionType from "../models/falukant/type/region.js";
|
||||||
import BranchType from "../models/falukant/type/branch.js";
|
import BranchType from "../models/falukant/type/branch.js";
|
||||||
|
import RegionDistance from "../models/falukant/data/region_distance.js";
|
||||||
import Room from '../models/chat/room.js';
|
import Room from '../models/chat/room.js';
|
||||||
import UserParam from '../models/community/user_param.js';
|
import UserParam from '../models/community/user_param.js';
|
||||||
|
|
||||||
@@ -298,6 +300,104 @@ class AdminService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getFalukantRegions(userId) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = await RegionData.findAll({
|
||||||
|
attributes: ['id', 'name', 'map'],
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: RegionType,
|
||||||
|
as: 'regionType',
|
||||||
|
where: { labelTr: 'city' },
|
||||||
|
attributes: ['labelTr'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['name', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateFalukantRegionMap(userId, regionId, map) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const region = await RegionData.findByPk(regionId);
|
||||||
|
if (!region) {
|
||||||
|
throw new Error('regionNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
region.map = map || {};
|
||||||
|
await region.save();
|
||||||
|
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRegionDistances(userId) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
const distances = await RegionDistance.findAll();
|
||||||
|
return distances;
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertRegionDistance(userId, { sourceRegionId, targetRegionId, transportMode, distance }) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceRegionId || !targetRegionId || !transportMode) {
|
||||||
|
throw new Error('missingParameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = await RegionData.findByPk(sourceRegionId);
|
||||||
|
const tgt = await RegionData.findByPk(targetRegionId);
|
||||||
|
if (!src || !tgt) {
|
||||||
|
throw new Error('regionNotFound');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = String(transportMode);
|
||||||
|
const dist = Number(distance);
|
||||||
|
if (!Number.isFinite(dist) || dist <= 0) {
|
||||||
|
throw new Error('invalidDistance');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [record] = await RegionDistance.findOrCreate({
|
||||||
|
where: {
|
||||||
|
sourceRegionId: src.id,
|
||||||
|
targetRegionId: tgt.id,
|
||||||
|
transportMode: mode,
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
distance: dist,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (record.distance !== dist) {
|
||||||
|
record.distance = dist;
|
||||||
|
await record.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegionDistance(userId, id) {
|
||||||
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
|
throw new Error('noaccess');
|
||||||
|
}
|
||||||
|
const record = await RegionDistance.findByPk(id);
|
||||||
|
if (!record) {
|
||||||
|
throw new Error('notfound');
|
||||||
|
}
|
||||||
|
await record.destroy();
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
async updateFalukantStock(userId, stockId, quantity) {
|
async updateFalukantStock(userId, stockId, quantity) {
|
||||||
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
if (!(await this.hasUserAccess(userId, 'falukantusers'))) {
|
||||||
throw new Error('noaccess');
|
throw new Error('noaccess');
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ import PoliticalOffice from '../models/falukant/data/political_office.js';
|
|||||||
import Underground from '../models/falukant/data/underground.js';
|
import Underground from '../models/falukant/data/underground.js';
|
||||||
import VehicleType from '../models/falukant/type/vehicle.js';
|
import VehicleType from '../models/falukant/type/vehicle.js';
|
||||||
import Vehicle from '../models/falukant/data/vehicle.js';
|
import Vehicle from '../models/falukant/data/vehicle.js';
|
||||||
|
import Transport from '../models/falukant/data/transport.js';
|
||||||
|
import RegionDistance from '../models/falukant/data/region_distance.js';
|
||||||
|
|
||||||
function calcAge(birthdate) {
|
function calcAge(birthdate) {
|
||||||
const b = new Date(birthdate); b.setHours(0, 0);
|
const b = new Date(birthdate); b.setHours(0, 0);
|
||||||
@@ -93,6 +95,85 @@ function calculateMarriageCost(titleOfNobility, age) {
|
|||||||
return baseCost * Math.pow(adjustedTitle, 1.3) - (age - 12) * 20;
|
return baseCost * Math.pow(adjustedTitle, 1.3) - (age - 12) * 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function computeShortestRoute(transportMode, sourceRegionId, targetRegionId) {
|
||||||
|
const src = parseInt(sourceRegionId, 10);
|
||||||
|
const tgt = parseInt(targetRegionId, 10);
|
||||||
|
if (Number.isNaN(src) || Number.isNaN(tgt)) {
|
||||||
|
throw new Error('Invalid region ids for route calculation');
|
||||||
|
}
|
||||||
|
if (src === tgt) {
|
||||||
|
return { distance: 0, path: [src] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await RegionDistance.findAll({
|
||||||
|
where: { transportMode },
|
||||||
|
attributes: ['sourceRegionId', 'targetRegionId', 'distance'],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!rows.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adj = new Map();
|
||||||
|
for (const r of rows) {
|
||||||
|
const a = r.sourceRegionId;
|
||||||
|
const b = r.targetRegionId;
|
||||||
|
const d = r.distance;
|
||||||
|
if (!adj.has(a)) adj.set(a, []);
|
||||||
|
if (!adj.has(b)) adj.set(b, []);
|
||||||
|
adj.get(a).push({ to: b, w: d });
|
||||||
|
adj.get(b).push({ to: a, w: d });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!adj.has(src) || !adj.has(tgt)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dist = new Map();
|
||||||
|
const prev = new Map();
|
||||||
|
const visited = new Set();
|
||||||
|
|
||||||
|
for (const node of adj.keys()) {
|
||||||
|
dist.set(node, Number.POSITIVE_INFINITY);
|
||||||
|
}
|
||||||
|
dist.set(src, 0);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
let u = null;
|
||||||
|
let best = Number.POSITIVE_INFINITY;
|
||||||
|
for (const [node, d] of dist.entries()) {
|
||||||
|
if (!visited.has(node) && d < best) {
|
||||||
|
best = d;
|
||||||
|
u = node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (u === null || u === tgt) break;
|
||||||
|
visited.add(u);
|
||||||
|
const neighbors = adj.get(u) || [];
|
||||||
|
for (const { to, w } of neighbors) {
|
||||||
|
if (visited.has(to)) continue;
|
||||||
|
const alt = dist.get(u) + w;
|
||||||
|
if (alt < dist.get(to)) {
|
||||||
|
dist.set(to, alt);
|
||||||
|
prev.set(to, u);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prev.has(tgt) && tgt !== src) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = [];
|
||||||
|
let cur = tgt;
|
||||||
|
while (cur !== undefined) {
|
||||||
|
path.unshift(cur);
|
||||||
|
cur = prev.get(cur);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { distance: dist.get(tgt), path };
|
||||||
|
}
|
||||||
|
|
||||||
class PreconditionError extends Error {
|
class PreconditionError extends Error {
|
||||||
constructor(label) {
|
constructor(label) {
|
||||||
super(label);
|
super(label);
|
||||||
@@ -404,6 +485,9 @@ class FalukantService extends BaseService {
|
|||||||
const exponentBase = Math.max(existingCount, 1);
|
const exponentBase = Math.max(existingCount, 1);
|
||||||
const rawCost = branchType.baseCost * Math.pow(exponentBase, 1.2);
|
const rawCost = branchType.baseCost * Math.pow(exponentBase, 1.2);
|
||||||
const cost = Math.round(rawCost * 100) / 100;
|
const cost = Math.round(rawCost * 100) / 100;
|
||||||
|
if (user.money < cost) {
|
||||||
|
throw new PreconditionError('insufficientFunds');
|
||||||
|
}
|
||||||
await updateFalukantUserMoney(
|
await updateFalukantUserMoney(
|
||||||
user.id,
|
user.id,
|
||||||
-cost,
|
-cost,
|
||||||
@@ -444,6 +528,41 @@ class FalukantService extends BaseService {
|
|||||||
return br;
|
return br;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upgradeBranch(hashedUserId, branchId) {
|
||||||
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
|
const branch = await Branch.findOne({
|
||||||
|
where: { id: branchId, falukantUserId: user.id },
|
||||||
|
include: [{ model: BranchType, as: 'branchType', attributes: ['id', 'labelTr'] }],
|
||||||
|
});
|
||||||
|
if (!branch) {
|
||||||
|
throw new Error('Branch not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLabel = branch.branchType?.labelTr;
|
||||||
|
|
||||||
|
let targetLabel = null;
|
||||||
|
if (currentLabel === 'production') {
|
||||||
|
targetLabel = 'fullstack';
|
||||||
|
} else if (currentLabel === 'store') {
|
||||||
|
targetLabel = 'fullstack';
|
||||||
|
} else {
|
||||||
|
// already fullstack or unknown type
|
||||||
|
throw new PreconditionError('noUpgradeAvailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetType = await BranchType.findOne({ where: { labelTr: targetLabel } });
|
||||||
|
if (!targetType) {
|
||||||
|
throw new Error(`Target branch type '${targetLabel}' not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Für den Moment ohne zusätzliche Kosten – kann später erweitert werden
|
||||||
|
branch.branchTypeId = targetType.id;
|
||||||
|
await branch.save();
|
||||||
|
|
||||||
|
const updated = await this.getBranch(hashedUserId, branch.id);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
async getStock(hashedUserId, branchId) {
|
async getStock(hashedUserId, branchId) {
|
||||||
const u = await getFalukantUserOrFail(hashedUserId);
|
const u = await getFalukantUserOrFail(hashedUserId);
|
||||||
const b = await getBranchOrFail(u.id, branchId);
|
const b = await getBranchOrFail(u.id, branchId);
|
||||||
@@ -456,7 +575,372 @@ class FalukantService extends BaseService {
|
|||||||
return VehicleType.findAll();
|
return VehicleType.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
async buyVehicles(hashedUserId, { vehicleTypeId, quantity, regionId }) {
|
async getVehicles(hashedUserId, regionId) {
|
||||||
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
|
|
||||||
|
const where = { falukantUserId: user.id };
|
||||||
|
if (regionId) {
|
||||||
|
where.regionId = regionId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vehicles = await Vehicle.findAll({
|
||||||
|
where,
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: VehicleType,
|
||||||
|
as: 'type',
|
||||||
|
attributes: ['id', 'tr', 'capacity', 'transportMode', 'speed', 'buildTimeMinutes', 'cost'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: Transport,
|
||||||
|
as: 'transports',
|
||||||
|
attributes: ['id', 'sourceRegionId', 'targetRegionId'],
|
||||||
|
required: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['availableFrom', 'ASC'], ['id', 'ASC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const branchRegionId = regionId ? parseInt(regionId, 10) : undefined;
|
||||||
|
|
||||||
|
return vehicles.map((v) => {
|
||||||
|
const plain = v.get({ plain: true });
|
||||||
|
|
||||||
|
const effectiveRegionId = branchRegionId ?? plain.regionId;
|
||||||
|
|
||||||
|
const hasTransportHere = Array.isArray(plain.transports) && plain.transports.some(
|
||||||
|
(t) =>
|
||||||
|
t.sourceRegionId === effectiveRegionId ||
|
||||||
|
t.targetRegionId === effectiveRegionId
|
||||||
|
);
|
||||||
|
|
||||||
|
let status;
|
||||||
|
if (hasTransportHere) {
|
||||||
|
// verknüpft mit Transport in dieser Region = unterwegs
|
||||||
|
status = 'travelling';
|
||||||
|
} else if (plain.availableFrom && new Date(plain.availableFrom).getTime() > now.getTime()) {
|
||||||
|
// kein Transport, aber Verfügbarkeit liegt in der Zukunft = im Bau
|
||||||
|
status = 'building';
|
||||||
|
} else {
|
||||||
|
// kein Transport und Verfügbarkeit erreicht = verfügbar
|
||||||
|
status = 'available';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: plain.id,
|
||||||
|
condition: plain.condition,
|
||||||
|
availableFrom: plain.availableFrom,
|
||||||
|
status,
|
||||||
|
type: {
|
||||||
|
id: plain.type?.id,
|
||||||
|
tr: plain.type?.tr,
|
||||||
|
capacity: plain.type?.capacity,
|
||||||
|
transportMode: plain.type?.transportMode,
|
||||||
|
speed: plain.type?.speed,
|
||||||
|
buildTimeMinutes: plain.type?.buildTimeMinutes,
|
||||||
|
cost: plain.type?.cost,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTransportRoute(hashedUserId, { sourceRegionId, targetRegionId, vehicleTypeId }) {
|
||||||
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = await VehicleType.findByPk(vehicleTypeId);
|
||||||
|
if (!type) {
|
||||||
|
throw new Error('Vehicle type not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = await computeShortestRoute(
|
||||||
|
type.transportMode,
|
||||||
|
sourceRegionId,
|
||||||
|
targetRegionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!route) {
|
||||||
|
return {
|
||||||
|
mode: type.transportMode,
|
||||||
|
totalDistance: null,
|
||||||
|
regions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const regions = await RegionData.findAll({
|
||||||
|
where: { id: route.path },
|
||||||
|
attributes: ['id', 'name'],
|
||||||
|
});
|
||||||
|
const regionMap = new Map(regions.map((r) => [r.id, r.name]));
|
||||||
|
const ordered = route.path.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: regionMap.get(id) || String(id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: type.transportMode,
|
||||||
|
totalDistance: route.distance,
|
||||||
|
regions: ordered,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTransport(hashedUserId, { branchId, vehicleTypeId, productId, quantity, targetBranchId }) {
|
||||||
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
|
|
||||||
|
const sourceBranch = await Branch.findOne({
|
||||||
|
where: { id: branchId, falukantUserId: user.id },
|
||||||
|
include: [{ model: RegionData, as: 'region' }],
|
||||||
|
});
|
||||||
|
if (!sourceBranch) {
|
||||||
|
throw new Error('Branch not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetBranch = await Branch.findOne({
|
||||||
|
where: { id: targetBranchId, falukantUserId: user.id },
|
||||||
|
include: [{ model: BranchType, as: 'branchType', attributes: ['labelTr'] }, { model: RegionData, as: 'region' }],
|
||||||
|
});
|
||||||
|
if (!targetBranch) {
|
||||||
|
throw new Error('Target branch not found');
|
||||||
|
}
|
||||||
|
if (!['store', 'fullstack'].includes(targetBranch.branchType.labelTr)) {
|
||||||
|
throw new PreconditionError('invalidTargetBranch');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceRegionId = sourceBranch.regionId;
|
||||||
|
const targetRegionId = targetBranch.regionId;
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const type = await VehicleType.findByPk(vehicleTypeId);
|
||||||
|
if (!type) {
|
||||||
|
throw new Error('Vehicle type not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = await computeShortestRoute(type.transportMode, sourceRegionId, targetRegionId);
|
||||||
|
if (!route) {
|
||||||
|
throw new PreconditionError('noRoute');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Freie Fahrzeuge dieses Typs in der Quell-Region
|
||||||
|
const vehicles = await Vehicle.findAll({
|
||||||
|
where: {
|
||||||
|
falukantUserId: user.id,
|
||||||
|
regionId: sourceRegionId,
|
||||||
|
vehicleTypeId,
|
||||||
|
availableFrom: { [Op.lte]: now },
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: Transport,
|
||||||
|
as: 'transports',
|
||||||
|
required: false,
|
||||||
|
attributes: ['id'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const freeVehicles = vehicles.filter((v) => {
|
||||||
|
const t = v.transports || [];
|
||||||
|
return t.length === 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!freeVehicles.length) {
|
||||||
|
throw new PreconditionError('noVehiclesAvailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacityPerVehicle = type.capacity || 0;
|
||||||
|
if (capacityPerVehicle <= 0) {
|
||||||
|
throw new Error('Invalid vehicle capacity');
|
||||||
|
}
|
||||||
|
const maxByVehicles = capacityPerVehicle * freeVehicles.length;
|
||||||
|
|
||||||
|
const stock = await FalukantStock.findOne({ where: { branchId: sourceBranch.id } });
|
||||||
|
if (!stock) {
|
||||||
|
throw new Error('Stock not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventory = await Inventory.findAll({
|
||||||
|
where: { stockId: stock.id },
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: ProductType,
|
||||||
|
as: 'productType',
|
||||||
|
required: true,
|
||||||
|
where: { id: productId },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const available = inventory.reduce((sum, i) => sum + i.quantity, 0);
|
||||||
|
if (available <= 0) {
|
||||||
|
throw new PreconditionError('noInventory');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxByInventory = available;
|
||||||
|
const hardMax = Math.min(maxByVehicles, maxByInventory);
|
||||||
|
|
||||||
|
const requested = Math.max(1, parseInt(quantity, 10) || 0);
|
||||||
|
if (requested > hardMax) {
|
||||||
|
throw new PreconditionError('quantityTooHigh');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transportkosten: 1 % des Warenwerts, mindestens 0,1
|
||||||
|
const productType = inventory[0]?.productType;
|
||||||
|
const unitValue = productType?.sellCost || 0;
|
||||||
|
const totalValue = unitValue * requested;
|
||||||
|
const transportCost = Math.max(0.1, totalValue * 0.01);
|
||||||
|
|
||||||
|
if (user.money < transportCost) {
|
||||||
|
throw new PreconditionError('insufficientFunds');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await sequelize.transaction(async (tx) => {
|
||||||
|
// Geld für den Transport abziehen
|
||||||
|
const moneyResult = await updateFalukantUserMoney(
|
||||||
|
user.id,
|
||||||
|
-transportCost,
|
||||||
|
'transport',
|
||||||
|
user.id
|
||||||
|
);
|
||||||
|
if (!moneyResult.success) {
|
||||||
|
throw new Error('Failed to update money');
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = requested;
|
||||||
|
const transportsCreated = [];
|
||||||
|
|
||||||
|
for (const v of freeVehicles) {
|
||||||
|
if (remaining <= 0) break;
|
||||||
|
const size = Math.min(remaining, capacityPerVehicle);
|
||||||
|
const t = await Transport.create(
|
||||||
|
{
|
||||||
|
sourceRegionId,
|
||||||
|
targetRegionId,
|
||||||
|
productId,
|
||||||
|
size,
|
||||||
|
vehicleId: v.id,
|
||||||
|
},
|
||||||
|
{ transaction: tx }
|
||||||
|
);
|
||||||
|
transportsCreated.push(t);
|
||||||
|
remaining -= size;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
throw new Error('Not enough vehicle capacity for requested quantity');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inventar in der Quell-Niederlassung reduzieren
|
||||||
|
let left = requested;
|
||||||
|
for (const inv of inventory) {
|
||||||
|
if (left <= 0) break;
|
||||||
|
if (inv.quantity <= left) {
|
||||||
|
left -= inv.quantity;
|
||||||
|
await inv.destroy({ transaction: tx });
|
||||||
|
} else {
|
||||||
|
await inv.update({ quantity: inv.quantity - left }, { transaction: tx });
|
||||||
|
left = 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left > 0) {
|
||||||
|
throw new Error('Inventory changed during transport creation');
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyUser(user.user.hashedId, 'stock_change', { branchId: sourceBranch.id });
|
||||||
|
notifyUser(user.user.hashedId, 'falukantBranchUpdate', { branchId: sourceBranch.id });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
maxQuantity: hardMax,
|
||||||
|
requested,
|
||||||
|
totalDistance: route.distance,
|
||||||
|
totalCost: transportCost,
|
||||||
|
transports: transportsCreated.map((t) => ({
|
||||||
|
id: t.id,
|
||||||
|
size: t.size,
|
||||||
|
vehicleId: t.vehicleId,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBranchTransports(hashedUserId, branchId) {
|
||||||
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
|
const branch = await Branch.findOne({
|
||||||
|
where: { id: branchId, falukantUserId: user.id },
|
||||||
|
include: [{ model: RegionData, as: 'region', attributes: ['id', 'name'] }],
|
||||||
|
});
|
||||||
|
if (!branch) {
|
||||||
|
throw new Error('Branch not found');
|
||||||
|
}
|
||||||
|
const regionId = branch.regionId;
|
||||||
|
|
||||||
|
const transports = await Transport.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ sourceRegionId: regionId },
|
||||||
|
{ targetRegionId: regionId },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
include: [
|
||||||
|
{ model: RegionData, as: 'sourceRegion', attributes: ['id', 'name'] },
|
||||||
|
{ model: RegionData, as: 'targetRegion', attributes: ['id', 'name'] },
|
||||||
|
{ model: ProductType, as: 'productType', attributes: ['id', 'labelTr'] },
|
||||||
|
{
|
||||||
|
model: Vehicle,
|
||||||
|
as: 'vehicle',
|
||||||
|
include: [
|
||||||
|
{
|
||||||
|
model: VehicleType,
|
||||||
|
as: 'type',
|
||||||
|
attributes: ['id', 'tr', 'speed'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
order: [['createdAt', 'DESC']],
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return transports.map((t) => {
|
||||||
|
const direction = t.sourceRegionId === regionId ? 'outgoing' : 'incoming';
|
||||||
|
|
||||||
|
let eta = null;
|
||||||
|
let durationHours = null;
|
||||||
|
if (t.vehicle && t.vehicle.type && t.vehicle.type.speed && t.sourceRegionId && t.targetRegionId) {
|
||||||
|
// Näherungsweise Dauer: wir haben die exakte Distanz hier nicht, deshalb nur anhand der
|
||||||
|
// bei Erstellung verwendeten Route in der UI berechnet – für die Liste reicht createdAt + 1h.
|
||||||
|
// TODO: Optional: Distanz persistent speichern.
|
||||||
|
durationHours = 1;
|
||||||
|
const createdAt = t.createdAt ? new Date(t.createdAt).getTime() : now;
|
||||||
|
const etaMs = createdAt + durationHours * 60 * 60 * 1000;
|
||||||
|
eta = new Date(etaMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: t.id,
|
||||||
|
direction,
|
||||||
|
sourceRegion: t.sourceRegion,
|
||||||
|
targetRegion: t.targetRegion,
|
||||||
|
product: t.productType,
|
||||||
|
size: t.size,
|
||||||
|
vehicleId: t.vehicleId,
|
||||||
|
vehicleType: t.vehicle?.type || null,
|
||||||
|
createdAt: t.createdAt,
|
||||||
|
eta,
|
||||||
|
durationHours,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async buyVehicles(hashedUserId, { vehicleTypeId, quantity, regionId, mode = 'buy' }) {
|
||||||
const user = await getFalukantUserOrFail(hashedUserId);
|
const user = await getFalukantUserOrFail(hashedUserId);
|
||||||
const qty = Math.max(1, parseInt(quantity, 10) || 0);
|
const qty = Math.max(1, parseInt(quantity, 10) || 0);
|
||||||
|
|
||||||
@@ -465,7 +949,11 @@ class FalukantService extends BaseService {
|
|||||||
throw new Error('Vehicle type not found');
|
throw new Error('Vehicle type not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalCost = type.cost * qty;
|
const baseCost = type.cost;
|
||||||
|
const unitCost = mode === 'build'
|
||||||
|
? Math.round(baseCost * 0.75)
|
||||||
|
: baseCost;
|
||||||
|
const totalCost = unitCost * qty;
|
||||||
if (user.money < totalCost) {
|
if (user.money < totalCost) {
|
||||||
throw new PreconditionError('insufficientFunds');
|
throw new PreconditionError('insufficientFunds');
|
||||||
}
|
}
|
||||||
@@ -481,16 +969,24 @@ class FalukantService extends BaseService {
|
|||||||
await updateFalukantUserMoney(
|
await updateFalukantUserMoney(
|
||||||
user.id,
|
user.id,
|
||||||
-totalCost,
|
-totalCost,
|
||||||
'buy_vehicles',
|
mode === 'build' ? 'build_vehicles' : 'buy_vehicles',
|
||||||
user.id
|
user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
const records = [];
|
const records = [];
|
||||||
|
const now = new Date();
|
||||||
|
const baseTime = now.getTime();
|
||||||
|
const buildMs = mode === 'build'
|
||||||
|
? (type.buildTimeMinutes || 0) * 60 * 1000
|
||||||
|
: 0;
|
||||||
|
|
||||||
for (let i = 0; i < qty; i++) {
|
for (let i = 0; i < qty; i++) {
|
||||||
records.push({
|
records.push({
|
||||||
vehicleTypeId: type.id,
|
vehicleTypeId: type.id,
|
||||||
falukantUserId: user.id,
|
falukantUserId: user.id,
|
||||||
regionId: region.id,
|
regionId: region.id,
|
||||||
|
availableFrom: new Date(baseTime + buildMs),
|
||||||
|
condition: 100,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await Vehicle.bulkCreate(records, { transaction: tx });
|
await Vehicle.bulkCreate(records, { transaction: tx });
|
||||||
@@ -2475,6 +2971,13 @@ class FalukantService extends BaseService {
|
|||||||
|
|
||||||
async checkBranchesRequirement(hashedUserId, requirement) {
|
async checkBranchesRequirement(hashedUserId, requirement) {
|
||||||
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
const user = await this.getFalukantUserByHashedId(hashedUserId);
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('User not found');
|
||||||
|
}
|
||||||
|
const branchCount = await Branch.count({
|
||||||
|
where: { falukantUserId: user.id }
|
||||||
|
});
|
||||||
|
return branchCount >= requirement.requirementValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getHealth(hashedUserId) {
|
async getHealth(hashedUserId) {
|
||||||
|
|||||||
@@ -276,13 +276,14 @@ const learnerTypes = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const vehicleTypes = [
|
const vehicleTypes = [
|
||||||
{ tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1 },
|
// build times (in minutes): 60, 90, 180, 300, 720, 120, 1440
|
||||||
{ tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2 },
|
{ tr: 'cargo_cart', name: 'Lastkarren', cost: 100, capacity: 20, transportMode: 'land', speed: 1, buildTimeMinutes: 60 },
|
||||||
{ tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3 },
|
{ tr: 'ox_cart', name: 'Ochsenkarren', cost: 200, capacity: 50, transportMode: 'land', speed: 2, buildTimeMinutes: 90 },
|
||||||
{ tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3 },
|
{ tr: 'small_carriage', name: 'kleine Pferdekutsche', cost: 300, capacity: 35, transportMode: 'land', speed: 3, buildTimeMinutes: 180 },
|
||||||
{ tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4 },
|
{ tr: 'large_carriage', name: 'große Pferdekutsche', cost: 1000, capacity: 100, transportMode: 'land', speed: 3, buildTimeMinutes: 300 },
|
||||||
{ tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1 },
|
{ tr: 'four_horse_carriage', name: 'Vierspänner', cost: 5000, capacity: 200, transportMode: 'land', speed: 4, buildTimeMinutes: 720 },
|
||||||
{ tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3 },
|
{ tr: 'raft', name: 'Floß', cost: 100, capacity: 25, transportMode: 'water', speed: 1, buildTimeMinutes: 120 },
|
||||||
|
{ tr: 'sailing_ship', name: 'Segelschiff', cost: 500, capacity: 200, transportMode: 'water', speed: 3, buildTimeMinutes: 1440 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const politicalOfficeBenefitTypes = [
|
const politicalOfficeBenefitTypes = [
|
||||||
@@ -897,16 +898,26 @@ export const initializeLearnerTypes = async () => {
|
|||||||
|
|
||||||
export const initializeVehicleTypes = async () => {
|
export const initializeVehicleTypes = async () => {
|
||||||
for (const v of vehicleTypes) {
|
for (const v of vehicleTypes) {
|
||||||
await VehicleType.findOrCreate({
|
const existing = await VehicleType.findOne({ where: { tr: v.tr } });
|
||||||
where: { tr: v.tr },
|
if (!existing) {
|
||||||
defaults: {
|
await VehicleType.create({
|
||||||
tr: v.tr,
|
tr: v.tr,
|
||||||
cost: v.cost,
|
cost: v.cost,
|
||||||
capacity: v.capacity,
|
capacity: v.capacity,
|
||||||
transportMode: v.transportMode,
|
transportMode: v.transportMode,
|
||||||
speed: v.speed,
|
speed: v.speed,
|
||||||
},
|
buildTimeMinutes: v.buildTimeMinutes,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// ensure new fields like cost/buildTime are updated if missing
|
||||||
|
await existing.update({
|
||||||
|
cost: v.cost,
|
||||||
|
capacity: v.capacity,
|
||||||
|
transportMode: v.transportMode,
|
||||||
|
speed: v.speed,
|
||||||
|
buildTimeMinutes: v.buildTimeMinutes,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 1.2 MiB |
BIN
frontend/public/images/falukant/map_old.png
Normal file
BIN
frontend/public/images/falukant/map_old.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 343 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="sale-section">
|
<div class="sale-section">
|
||||||
<!-- Beispielhafte Inventar-Tabelle -->
|
<!-- Inventar-Tabelle -->
|
||||||
<div v-if="inventory.length > 0" class="inventory-table">
|
<div v-if="inventory.length > 0" class="inventory-table">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -30,6 +30,121 @@
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
<p>{{ $t('falukant.branch.sale.noInventory') }}</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -37,14 +152,46 @@
|
|||||||
import apiClient from '@/utils/axios.js';
|
import apiClient from '@/utils/axios.js';
|
||||||
export default {
|
export default {
|
||||||
name: "SaleSection",
|
name: "SaleSection",
|
||||||
props: { branchId: { type: Number, required: true } },
|
props: {
|
||||||
|
branchId: { type: Number, required: true },
|
||||||
|
vehicles: { type: Array, default: () => [] },
|
||||||
|
branches: { type: Array, default: () => [] },
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
inventory: [],
|
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() {
|
async mounted() {
|
||||||
await this.loadInventory();
|
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: {
|
methods: {
|
||||||
async loadInventory() {
|
async loadInventory() {
|
||||||
@@ -76,6 +223,205 @@
|
|||||||
alert(this.$t('falukant.branch.sale.sellAllError'));
|
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>
|
</script>
|
||||||
@@ -96,5 +442,11 @@
|
|||||||
padding: 2px 3px;
|
padding: 2px 3px;
|
||||||
border: 1px solid #ddd;
|
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>
|
</style>
|
||||||
|
|
||||||
@@ -24,6 +24,18 @@
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label class="form-label">
|
||||||
|
{{ $t('falukant.branch.transport.mode') }}
|
||||||
|
<select v-model="mode" class="form-control">
|
||||||
|
<option value="buy">
|
||||||
|
{{ $t('falukant.branch.transport.modeBuy') }}
|
||||||
|
</option>
|
||||||
|
<option value="build">
|
||||||
|
{{ $t('falukant.branch.transport.modeBuild') }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label class="form-label">
|
<label class="form-label">
|
||||||
{{ $t('falukant.branch.transport.quantity') }}
|
{{ $t('falukant.branch.transport.quantity') }}
|
||||||
<input
|
<input
|
||||||
@@ -39,6 +51,11 @@
|
|||||||
<strong>{{ formatCost(totalCost) }}</strong>
|
<strong>{{ formatCost(totalCost) }}</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p v-if="selectedType" class="buildtime">
|
||||||
|
{{ $t('falukant.branch.transport.buildTime') }}:
|
||||||
|
<span>{{ formattedBuildTime }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p v-if="totalCost > money" class="warning">
|
<p v-if="totalCost > money" class="warning">
|
||||||
{{ $t('falukant.branch.transport.notEnoughMoney') }}
|
{{ $t('falukant.branch.transport.notEnoughMoney') }}
|
||||||
</p>
|
</p>
|
||||||
@@ -64,6 +81,7 @@ export default {
|
|||||||
vehicleTypes: [],
|
vehicleTypes: [],
|
||||||
selectedTypeId: null,
|
selectedTypeId: null,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
|
mode: 'buy',
|
||||||
money: 0,
|
money: 0,
|
||||||
loaded: false,
|
loaded: false,
|
||||||
};
|
};
|
||||||
@@ -73,7 +91,10 @@ export default {
|
|||||||
return [
|
return [
|
||||||
{ text: this.$t('Cancel'), action: this.close },
|
{ text: this.$t('Cancel'), action: this.close },
|
||||||
{
|
{
|
||||||
text: this.$t('falukant.branch.transport.buy'),
|
text:
|
||||||
|
this.mode === 'build'
|
||||||
|
? this.$t('falukant.branch.transport.buildAction')
|
||||||
|
: this.$t('falukant.branch.transport.buyAction'),
|
||||||
action: this.onConfirm,
|
action: this.onConfirm,
|
||||||
disabled: !this.canBuy,
|
disabled: !this.canBuy,
|
||||||
},
|
},
|
||||||
@@ -85,7 +106,11 @@ export default {
|
|||||||
totalCost() {
|
totalCost() {
|
||||||
if (!this.selectedType) return 0;
|
if (!this.selectedType) return 0;
|
||||||
const q = Math.max(1, this.quantity || 0);
|
const q = Math.max(1, this.quantity || 0);
|
||||||
return this.selectedType.cost * q;
|
const unit =
|
||||||
|
this.mode === 'build'
|
||||||
|
? Math.round(this.selectedType.cost * 0.75)
|
||||||
|
: this.selectedType.cost;
|
||||||
|
return unit * q;
|
||||||
},
|
},
|
||||||
canBuy() {
|
canBuy() {
|
||||||
return (
|
return (
|
||||||
@@ -99,11 +124,23 @@ export default {
|
|||||||
formattedMoney() {
|
formattedMoney() {
|
||||||
return this.formatCost(this.money);
|
return this.formatCost(this.money);
|
||||||
},
|
},
|
||||||
|
formattedBuildTime() {
|
||||||
|
if (!this.selectedType || !this.selectedType.buildTimeMinutes) return '-';
|
||||||
|
const total = this.selectedType.buildTimeMinutes;
|
||||||
|
const h = Math.floor(total / 60);
|
||||||
|
const m = total % 60;
|
||||||
|
if (h > 0 && m > 0) {
|
||||||
|
return `${h} h ${m} min`;
|
||||||
|
}
|
||||||
|
if (h > 0) return `${h} h`;
|
||||||
|
return `${m} min`;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async open() {
|
async open() {
|
||||||
this.loaded = false;
|
this.loaded = false;
|
||||||
this.quantity = 1;
|
this.quantity = 1;
|
||||||
|
this.mode = 'buy';
|
||||||
await Promise.all([this.loadVehicleTypes(), this.loadMoney()]);
|
await Promise.all([this.loadVehicleTypes(), this.loadMoney()]);
|
||||||
if (this.vehicleTypes.length && !this.selectedTypeId) {
|
if (this.vehicleTypes.length && !this.selectedTypeId) {
|
||||||
this.selectedTypeId = this.vehicleTypes[0].id;
|
this.selectedTypeId = this.vehicleTypes[0].id;
|
||||||
@@ -139,6 +176,7 @@ export default {
|
|||||||
vehicleTypeId: this.selectedTypeId,
|
vehicleTypeId: this.selectedTypeId,
|
||||||
quantity: this.quantity,
|
quantity: this.quantity,
|
||||||
regionId: this.regionId,
|
regionId: this.regionId,
|
||||||
|
mode: this.mode,
|
||||||
});
|
});
|
||||||
this.$emit('bought');
|
this.$emit('bought');
|
||||||
this.close();
|
this.close();
|
||||||
|
|||||||
@@ -11,15 +11,18 @@
|
|||||||
<div class="create-branch-form">
|
<div class="create-branch-form">
|
||||||
<div class="map-wrapper">
|
<div class="map-wrapper">
|
||||||
<!-- linke Spalte: Karte + Regionen + Dev-Draw -->
|
<!-- linke Spalte: Karte + Regionen + Dev-Draw -->
|
||||||
<div class="map-container">
|
<div
|
||||||
<img
|
class="map-container"
|
||||||
ref="mapImage"
|
|
||||||
src="/images/falukant/map.png"
|
|
||||||
class="map"
|
|
||||||
@mousedown="onMouseDown"
|
@mousedown="onMouseDown"
|
||||||
@mousemove="onMouseMove"
|
@mousemove="onMouseMove"
|
||||||
@mouseup="onMouseUp"
|
@mouseup="onMouseUp"
|
||||||
@mouseleave="onMouseUp"
|
@mouseleave="onMouseUp"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref="mapImage"
|
||||||
|
src="/images/falukant/map.png"
|
||||||
|
class="map"
|
||||||
|
@load="onMapLoaded"
|
||||||
@dragstart.prevent
|
@dragstart.prevent
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
@@ -27,12 +30,7 @@
|
|||||||
:key="city.name"
|
:key="city.name"
|
||||||
class="city-region"
|
class="city-region"
|
||||||
:class="city.branches.length > 0 ? 'has-branch' : 'clickable'"
|
:class="city.branches.length > 0 ? 'has-branch' : 'clickable'"
|
||||||
:style="{
|
:style="cityRegionStyle(city.map)"
|
||||||
top: city.map.y + 'px',
|
|
||||||
left: city.map.x + 'px',
|
|
||||||
width: city.map.w + 'px',
|
|
||||||
height: city.map.h + 'px'
|
|
||||||
}"
|
|
||||||
@click="city.branches.length === 0 && onCityClick(city)"
|
@click="city.branches.length === 0 && onCityClick(city)"
|
||||||
:title="city.name"
|
:title="city.name"
|
||||||
></div>
|
></div>
|
||||||
@@ -102,6 +100,8 @@
|
|||||||
startY: null,
|
startY: null,
|
||||||
currentX: 0,
|
currentX: 0,
|
||||||
currentY: 0,
|
currentY: 0,
|
||||||
|
mapWidth: 0,
|
||||||
|
mapHeight: 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -130,6 +130,11 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
onMapLoaded() {
|
||||||
|
const bounds = this.$refs.mapImage.getBoundingClientRect();
|
||||||
|
this.mapWidth = bounds.width;
|
||||||
|
this.mapHeight = bounds.height;
|
||||||
|
},
|
||||||
open() {
|
open() {
|
||||||
this.$refs.dialog.open();
|
this.$refs.dialog.open();
|
||||||
},
|
},
|
||||||
@@ -149,6 +154,12 @@
|
|||||||
this.$emit('create-branch');
|
this.$emit('create-branch');
|
||||||
this.close();
|
this.close();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e?.response?.status === 412 && e?.response?.data?.error === 'insufficientFunds') {
|
||||||
|
alert(this.$t('falukant.branch.actions.insufficientFunds'));
|
||||||
|
} else {
|
||||||
|
console.error('Error creating branch', e);
|
||||||
|
alert(this.$t('falukant.error.generic') || 'Fehler beim Erstellen der Niederlassung.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -208,6 +219,21 @@
|
|||||||
height: Math.round(height),
|
height: Math.round(height),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
cityRegionStyle(map) {
|
||||||
|
if (!map || !this.mapWidth || !this.mapHeight) return {};
|
||||||
|
const toPxX = (v) => (v >= 0 && v <= 1 ? v * this.mapWidth : v);
|
||||||
|
const toPxY = (v) => (v >= 0 && v <= 1 ? v * this.mapHeight : v);
|
||||||
|
const x = toPxX(map.x);
|
||||||
|
const y = toPxY(map.y);
|
||||||
|
const w = toPxX(map.w);
|
||||||
|
const h = toPxY(map.h);
|
||||||
|
return {
|
||||||
|
top: `${y}px`,
|
||||||
|
left: `${x}px`,
|
||||||
|
width: `${w}px`,
|
||||||
|
height: `${h}px`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async loadCities() {
|
async loadCities() {
|
||||||
const { data } = await apiClient.get('/api/falukant/cities');
|
const { data } = await apiClient.get('/api/falukant/cities');
|
||||||
|
|||||||
@@ -85,6 +85,34 @@
|
|||||||
"errorAddingStock": "Fehler beim Hinzufügen des Lagers.",
|
"errorAddingStock": "Fehler beim Hinzufügen des Lagers.",
|
||||||
"stockAdded": "Lager erfolgreich hinzugefügt.",
|
"stockAdded": "Lager erfolgreich hinzugefügt.",
|
||||||
"invalidStockData": "Bitte gültige Lagertyp- und Mengenangaben eingeben."
|
"invalidStockData": "Bitte gültige Lagertyp- und Mengenangaben eingeben."
|
||||||
|
},
|
||||||
|
"map": {
|
||||||
|
"title": "Falukant Karten-Editor (Regionen)",
|
||||||
|
"description": "Zeichne Rechtecke auf der Falukant-Karte und weise sie Städten zu.",
|
||||||
|
"tabs": {
|
||||||
|
"regions": "Positionen",
|
||||||
|
"distances": "Entfernungen"
|
||||||
|
},
|
||||||
|
"regionList": "Städte",
|
||||||
|
"noCoords": "Keine Koordinaten gesetzt",
|
||||||
|
"currentRect": "Aktuelles Rechteck",
|
||||||
|
"hintDraw": "Wähle eine Stadt und ziehe mit der Maus ein Rechteck auf der Karte, um die Position festzulegen.",
|
||||||
|
"saveAll": "Alle geänderten Städte speichern",
|
||||||
|
"connectionsTitle": "Verbindungen (region_distance)",
|
||||||
|
"source": "Von",
|
||||||
|
"target": "Nach",
|
||||||
|
"selectSource": "Quellstadt wählen",
|
||||||
|
"selectTarget": "Zielstadt wählen",
|
||||||
|
"mode": "Transportart",
|
||||||
|
"modeLand": "Land",
|
||||||
|
"modeWater": "Wasser",
|
||||||
|
"modeAir": "Luft",
|
||||||
|
"distance": "Entfernung",
|
||||||
|
"saveConnection": "Verbindung speichern",
|
||||||
|
"pickOnMap": "Auf Karte wählen",
|
||||||
|
"errorSaveConnection": "Die Verbindung konnte nicht gespeichert werden.",
|
||||||
|
"errorDeleteConnection": "Die Verbindung konnte nicht gelöscht werden.",
|
||||||
|
"confirmDeleteConnection": "Verbindung wirklich löschen?"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"chatrooms": {
|
"chatrooms": {
|
||||||
|
|||||||
@@ -147,7 +147,33 @@
|
|||||||
"loadError": "Fehler beim Laden des Inventars.",
|
"loadError": "Fehler beim Laden des Inventars.",
|
||||||
"sell": "Verkauf",
|
"sell": "Verkauf",
|
||||||
"sellButton": "Verkaufen",
|
"sellButton": "Verkaufen",
|
||||||
"sellAllButton": "Alles verkaufen"
|
"sellAllButton": "Alles verkaufen",
|
||||||
|
"transportTitle": "Transport anlegen",
|
||||||
|
"transportSource": "Artikel",
|
||||||
|
"transportSourcePlaceholder": "Artikel wählen",
|
||||||
|
"transportVehicle": "Transportmittel",
|
||||||
|
"transportVehiclePlaceholder": "Transportmittel wählen",
|
||||||
|
"transportTarget": "Zielstadt",
|
||||||
|
"transportTargetPlaceholder": "Ziel wählen",
|
||||||
|
"transportQuantity": "Menge",
|
||||||
|
"transportMax": "Maximal: {max}",
|
||||||
|
"transportCreate": "Transport starten",
|
||||||
|
"transportError": "Transport konnte nicht angelegt werden.",
|
||||||
|
"transportDuration": "Transportdauer: {duration}",
|
||||||
|
"transportArrival": "Ankunftszeit: {datetime}",
|
||||||
|
"transportRoute": "Route",
|
||||||
|
"transportCost": "Transportkosten: {cost}",
|
||||||
|
"transportStarted": "Der Transport wurde gestartet.",
|
||||||
|
"runningTransportsTitle": "Laufende Transporte",
|
||||||
|
"runningDirection": "Richtung",
|
||||||
|
"runningProduct": "Artikel",
|
||||||
|
"runningQuantity": "Menge",
|
||||||
|
"runningSource": "Quelle",
|
||||||
|
"runningTarget": "Ziel",
|
||||||
|
"runningEta": "Ankunft",
|
||||||
|
"runningRemaining": "Restzeit",
|
||||||
|
"runningDirectionOut": "Ausgehend",
|
||||||
|
"runningDirectionIn": "Eingehend"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"title": "Produktion",
|
"title": "Produktion",
|
||||||
@@ -215,13 +241,34 @@
|
|||||||
},
|
},
|
||||||
"transport": {
|
"transport": {
|
||||||
"title": "Transportmittel",
|
"title": "Transportmittel",
|
||||||
"placeholder": "Hier kannst du Transportmittel für deine Region kaufen.",
|
"placeholder": "Hier kannst du Transportmittel für deine Region kaufen oder bauen.",
|
||||||
"vehicleType": "Transportmittel",
|
"vehicleType": "Transportmittel",
|
||||||
|
"mode": "Art",
|
||||||
|
"modeBuy": "Kaufen (sofort verfügbar)",
|
||||||
|
"modeBuild": "Bauen (75 % Kosten, mit Bauzeit)",
|
||||||
"quantity": "Anzahl",
|
"quantity": "Anzahl",
|
||||||
"totalCost": "Gesamtkosten",
|
"totalCost": "Gesamtkosten",
|
||||||
"notEnoughMoney": "Du hast nicht genug Geld für diesen Kauf.",
|
"buildTime": "Bauzeit",
|
||||||
"buy": "Transportmittel kaufen",
|
"notEnoughMoney": "Du hast nicht genug Geld für diese Aktion.",
|
||||||
"balance": "Kontostand"
|
"buyAction": "Transportmittel kaufen",
|
||||||
|
"buildAction": "Transportmittel bauen",
|
||||||
|
"buy": "Transportmittel kaufen/bauen",
|
||||||
|
"balance": "Kontostand",
|
||||||
|
"noVehicles": "Du besitzt in dieser Region noch keine Transportmittel.",
|
||||||
|
"table": {
|
||||||
|
"type": "Typ",
|
||||||
|
"capacity": "Kapazität",
|
||||||
|
"condition": "Zustand",
|
||||||
|
"mode": "Art",
|
||||||
|
"speed": "Geschwindigkeit",
|
||||||
|
"availableFrom": "Verfügbar ab",
|
||||||
|
"status": "Status"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"inUse": "In Benutzung (mit Transport verknüpft)",
|
||||||
|
"building": "Im Bau",
|
||||||
|
"free": "Verfügbar"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"stocktype": {
|
"stocktype": {
|
||||||
"wood": "Holzlager",
|
"wood": "Holzlager",
|
||||||
@@ -358,7 +405,11 @@
|
|||||||
"Buy storage (type: field)": "Lagerplatz gekauft (Typ: Feld)",
|
"Buy storage (type: field)": "Lagerplatz gekauft (Typ: Feld)",
|
||||||
"Buy storage (type: iron)": "Lagerplatz gekauft (Typ: Eisen)",
|
"Buy storage (type: iron)": "Lagerplatz gekauft (Typ: Eisen)",
|
||||||
"Buy storage (type: stone)": "Lagerplatz gekauft (Typ: Stein)",
|
"Buy storage (type: stone)": "Lagerplatz gekauft (Typ: Stein)",
|
||||||
"Buy storage (type: wood)": "Lagerplatz gekauft (Typ: Holz)"
|
"Buy storage (type: wood)": "Lagerplatz gekauft (Typ: Holz)",
|
||||||
|
"create_branch": "Niederlassung gegründet",
|
||||||
|
"buy_vehicles": "Transportmittel gekauft",
|
||||||
|
"build_vehicles": "Transportmittel gebaut",
|
||||||
|
"transport": "Transport"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"newdirector": {
|
"newdirector": {
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
"m-falukant": {
|
"m-falukant": {
|
||||||
"logentries": "Log-Einträge",
|
"logentries": "Log-Einträge",
|
||||||
"edituser": "Benutzer bearbeiten",
|
"edituser": "Benutzer bearbeiten",
|
||||||
"database": "Datenbank"
|
"database": "Datenbank",
|
||||||
|
"mapEditor": "Karteneditor"
|
||||||
},
|
},
|
||||||
"minigames": "Minispiele",
|
"minigames": "Minispiele",
|
||||||
"m-minigames": {
|
"m-minigames": {
|
||||||
|
|||||||
@@ -57,7 +57,8 @@
|
|||||||
"m-falukant": {
|
"m-falukant": {
|
||||||
"logentries": "Log entries",
|
"logentries": "Log entries",
|
||||||
"edituser": "Edit user",
|
"edituser": "Edit user",
|
||||||
"database": "Database"
|
"database": "Database",
|
||||||
|
"mapEditor": "Map editor"
|
||||||
},
|
},
|
||||||
"minigames": "Mini games",
|
"minigames": "Mini games",
|
||||||
"m-minigames": {
|
"m-minigames": {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import RoomsView from '../views/admin/RoomsView.vue';
|
|||||||
import UserRightsView from '../views/admin/UserRightsView.vue';
|
import UserRightsView from '../views/admin/UserRightsView.vue';
|
||||||
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
|
import ForumAdminView from '../dialogues/admin/ForumAdminView.vue';
|
||||||
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
|
import AdminFalukantEditUserView from '../views/admin/falukant/EditUserView.vue';
|
||||||
|
import AdminFalukantMapRegionsView from '../views/admin/falukant/MapRegionsView.vue';
|
||||||
import AdminMinigamesView from '../views/admin/MinigamesView.vue';
|
import AdminMinigamesView from '../views/admin/MinigamesView.vue';
|
||||||
import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue';
|
import AdminTaxiToolsView from '../views/admin/TaxiToolsView.vue';
|
||||||
import AdminUsersView from '../views/admin/UsersView.vue';
|
import AdminUsersView from '../views/admin/UsersView.vue';
|
||||||
@@ -59,6 +60,12 @@ const adminRoutes = [
|
|||||||
component: AdminFalukantEditUserView,
|
component: AdminFalukantEditUserView,
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/falukant/map',
|
||||||
|
name: 'AdminFalukantMapRegionsView',
|
||||||
|
component: AdminFalukantMapRegionsView,
|
||||||
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/admin/minigames/match3',
|
path: '/admin/minigames/match3',
|
||||||
name: 'AdminMinigames',
|
name: 'AdminMinigames',
|
||||||
|
|||||||
631
frontend/src/views/admin/falukant/MapRegionsView.vue
Normal file
631
frontend/src/views/admin/falukant/MapRegionsView.vue
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
<template>
|
||||||
|
<div class="contenthidden">
|
||||||
|
<div class="contentscroll falukant-map-admin">
|
||||||
|
<div class="admin-header">
|
||||||
|
<h1>{{ $t('admin.falukant.map.title') }}</h1>
|
||||||
|
<p>{{ $t('admin.falukant.map.description') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-layout">
|
||||||
|
<div
|
||||||
|
class="map-container"
|
||||||
|
@mousedown="onMouseDown"
|
||||||
|
@mousemove="onMouseMove"
|
||||||
|
@mouseup="onMouseUp"
|
||||||
|
@mouseleave="onMouseUp"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
ref="mapImage"
|
||||||
|
src="/images/falukant/map.png"
|
||||||
|
class="map"
|
||||||
|
@load="onMapLoaded"
|
||||||
|
@dragstart.prevent
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- vorhandene Regionen-Rechtecke -->
|
||||||
|
<template v-for="region in regions" :key="region.id">
|
||||||
|
<div
|
||||||
|
v-if="region.map"
|
||||||
|
class="region-rect"
|
||||||
|
:class="regionClasses(region)"
|
||||||
|
:style="rectStyle(region.map)"
|
||||||
|
@click.stop="selectRegion(region)"
|
||||||
|
:title="region.name"
|
||||||
|
></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- aktuell gezeichneter Bereich -->
|
||||||
|
<div
|
||||||
|
v-if="drawingRect"
|
||||||
|
class="region-rect drawing"
|
||||||
|
:style="rectStyle(drawingRect)"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar">
|
||||||
|
<h2>{{ $t('admin.falukant.map.regionList') }}</h2>
|
||||||
|
<ul class="region-list">
|
||||||
|
<li
|
||||||
|
v-for="region in regions"
|
||||||
|
:key="region.id"
|
||||||
|
:class="regionListClasses(region)"
|
||||||
|
@click="selectRegion(region)"
|
||||||
|
>
|
||||||
|
{{ region.name }}
|
||||||
|
<span v-if="region.map" class="coords">
|
||||||
|
({{ region.map.x }},{{ region.map.y }} {{ region.map.w }}×{{ region.map.h }})
|
||||||
|
</span>
|
||||||
|
<span v-else class="coords missing">
|
||||||
|
{{ $t('admin.falukant.map.noCoords') }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<SimpleTabs v-model="activeTab" :tabs="tabs" />
|
||||||
|
|
||||||
|
<div v-if="activeTab === 'regions'">
|
||||||
|
<div v-if="selectedRegion" class="details">
|
||||||
|
<h3>{{ selectedRegion.name }}</h3>
|
||||||
|
<p v-if="selectedRegion.map">
|
||||||
|
{{ $t('admin.falukant.map.currentRect') }}:
|
||||||
|
{{ selectedRegion.map.x }},{{ selectedRegion.map.y }}
|
||||||
|
{{ selectedRegion.map.w }}×{{ selectedRegion.map.h }}
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
{{ $t('admin.falukant.map.noCoords') }}
|
||||||
|
</p>
|
||||||
|
<p class="hint">
|
||||||
|
{{ $t('admin.falukant.map.hintDraw') }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!selectedRegionDirty"
|
||||||
|
@click="saveSelectedRegion"
|
||||||
|
>
|
||||||
|
{{ $t('common.save') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
:disabled="!dirtyRegionIds.length"
|
||||||
|
@click="saveAllRegions"
|
||||||
|
>
|
||||||
|
{{ $t('admin.falukant.map.saveAll') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="activeTab === 'distances'" class="connections">
|
||||||
|
<h3>{{ $t('admin.falukant.map.connectionsTitle') }}</h3>
|
||||||
|
<div class="connection-form">
|
||||||
|
<table class="connection-form-table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell">
|
||||||
|
{{ $t('admin.falukant.map.source') }}
|
||||||
|
</td>
|
||||||
|
<td class="field-cell">
|
||||||
|
<div class="field-row">
|
||||||
|
<select v-model.number="newConnection.sourceRegionId">
|
||||||
|
<option :value="null" disabled>{{ $t('admin.falukant.map.selectSource') }}</option>
|
||||||
|
<option v-for="r in regions" :key="`src-${r.id}`" :value="r.id">
|
||||||
|
{{ r.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn mini icon"
|
||||||
|
@click="pickMode = 'source'"
|
||||||
|
:title="$t('admin.falukant.map.pickOnMap')"
|
||||||
|
>
|
||||||
|
⊕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell">
|
||||||
|
{{ $t('admin.falukant.map.target') }}
|
||||||
|
</td>
|
||||||
|
<td class="field-cell">
|
||||||
|
<div class="field-row">
|
||||||
|
<select v-model.number="newConnection.targetRegionId">
|
||||||
|
<option :value="null" disabled>{{ $t('admin.falukant.map.selectTarget') }}</option>
|
||||||
|
<option v-for="r in regions" :key="`tgt-${r.id}`" :value="r.id">
|
||||||
|
{{ r.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn mini icon"
|
||||||
|
@click="pickMode = 'target'"
|
||||||
|
:title="$t('admin.falukant.map.pickOnMap')"
|
||||||
|
>
|
||||||
|
⊕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell">
|
||||||
|
{{ $t('admin.falukant.map.mode') }}
|
||||||
|
</td>
|
||||||
|
<td class="field-cell">
|
||||||
|
<select v-model="newConnection.transportMode">
|
||||||
|
<option value="land">{{ $t('admin.falukant.map.modeLand') }}</option>
|
||||||
|
<option value="water">{{ $t('admin.falukant.map.modeWater') }}</option>
|
||||||
|
<option value="air">{{ $t('admin.falukant.map.modeAir') }}</option>
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell">
|
||||||
|
{{ $t('admin.falukant.map.distance') }}
|
||||||
|
</td>
|
||||||
|
<td class="field-cell">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0.1"
|
||||||
|
step="0.1"
|
||||||
|
v-model.number="newConnection.distance"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2" class="connection-actions-cell">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="!newConnection.sourceRegionId || !newConnection.targetRegionId || !newConnection.distance"
|
||||||
|
@click="saveConnection"
|
||||||
|
>
|
||||||
|
{{ $t('admin.falukant.map.saveConnection') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table v-if="connections && connections.length" class="connections-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('admin.falukant.map.source') }}</th>
|
||||||
|
<th>{{ $t('admin.falukant.map.target') }}</th>
|
||||||
|
<th>{{ $t('admin.falukant.map.mode') }}</th>
|
||||||
|
<th>{{ $t('admin.falukant.map.distance') }}</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="c in connections" :key="c.id">
|
||||||
|
<td>{{ regionName(c.sourceRegionId) }}</td>
|
||||||
|
<td>{{ regionName(c.targetRegionId) }}</td>
|
||||||
|
<td>{{ c.transportMode }}</td>
|
||||||
|
<td>{{ c.distance }}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-secondary" @click="deleteConnection(c.id)">
|
||||||
|
{{ $t('common.delete') }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import apiClient from '@/utils/axios.js';
|
||||||
|
import SimpleTabs from '@/components/SimpleTabs.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'AdminFalukantMapRegionsView',
|
||||||
|
components: { SimpleTabs },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
regions: [],
|
||||||
|
selectedRegion: null,
|
||||||
|
selectedRegionDirty: false,
|
||||||
|
dirtyRegionIds: [],
|
||||||
|
drawingRect: null,
|
||||||
|
startX: null,
|
||||||
|
startY: null,
|
||||||
|
currentX: 0,
|
||||||
|
currentY: 0,
|
||||||
|
connections: [],
|
||||||
|
newConnection: {
|
||||||
|
sourceRegionId: null,
|
||||||
|
targetRegionId: null,
|
||||||
|
transportMode: 'land',
|
||||||
|
distance: 1,
|
||||||
|
},
|
||||||
|
activeTab: 'regions',
|
||||||
|
tabs: [
|
||||||
|
{ value: 'regions', label: 'admin.falukant.map.tabs.regions' },
|
||||||
|
{ value: 'distances', label: 'admin.falukant.map.tabs.distances' },
|
||||||
|
],
|
||||||
|
pickMode: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.loadRegions();
|
||||||
|
await this.loadConnections();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
onMapLoaded() {
|
||||||
|
const bounds = this.$refs.mapImage.getBoundingClientRect();
|
||||||
|
this.mapWidth = bounds.width;
|
||||||
|
this.mapHeight = bounds.height;
|
||||||
|
},
|
||||||
|
async loadRegions() {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/admin/falukant/regions');
|
||||||
|
// Sicherstellen, dass map-Objekte existieren oder null sind
|
||||||
|
this.regions = (data || []).map(r => ({
|
||||||
|
...r,
|
||||||
|
map: r.map || null,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading Falukant regions:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async loadConnections() {
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/admin/falukant/region-distances');
|
||||||
|
this.connections = data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading region distances:', error);
|
||||||
|
this.connections = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rectStyle(rect) {
|
||||||
|
if (!rect || !this.mapWidth || !this.mapHeight) return {};
|
||||||
|
|
||||||
|
// Unterstützt alte absolute Koordinaten (Pixel) und neue relative (0–1)
|
||||||
|
const toPxX = (v) => (v >= 0 && v <= 1 ? v * this.mapWidth : v);
|
||||||
|
const toPxY = (v) => (v >= 0 && v <= 1 ? v * this.mapHeight : v);
|
||||||
|
|
||||||
|
const x = toPxX(rect.x);
|
||||||
|
const y = toPxY(rect.y);
|
||||||
|
const w = toPxX(rect.w);
|
||||||
|
const h = toPxY(rect.h);
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${y}px`,
|
||||||
|
left: `${x}px`,
|
||||||
|
width: `${w}px`,
|
||||||
|
height: `${h}px`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
regionClasses(region) {
|
||||||
|
return {
|
||||||
|
selected: this.selectedRegion && this.selectedRegion.id === region.id,
|
||||||
|
dirty: this.dirtyRegionIds.includes(region.id),
|
||||||
|
source: this.newConnection.sourceRegionId === region.id,
|
||||||
|
target: this.newConnection.targetRegionId === region.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
regionListClasses(region) {
|
||||||
|
return {
|
||||||
|
selected: this.selectedRegion && this.selectedRegion.id === region.id,
|
||||||
|
dirty: this.dirtyRegionIds.includes(region.id),
|
||||||
|
source: this.newConnection.sourceRegionId === region.id,
|
||||||
|
target: this.newConnection.targetRegionId === region.id,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
selectRegion(region) {
|
||||||
|
// Wenn wir im "auf Karte wählen"-Modus sind, setze Quelle/Ziel für die Verbindung
|
||||||
|
if (this.pickMode === 'source') {
|
||||||
|
this.newConnection.sourceRegionId = region.id;
|
||||||
|
this.pickMode = null;
|
||||||
|
} else if (this.pickMode === 'target') {
|
||||||
|
this.newConnection.targetRegionId = region.id;
|
||||||
|
this.pickMode = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.selectedRegion = region;
|
||||||
|
this.selectedRegionDirty = this.dirtyRegionIds.includes(region.id);
|
||||||
|
this.drawingRect = null;
|
||||||
|
},
|
||||||
|
onMouseDown(event) {
|
||||||
|
// Zeichnen von Rechtecken nur im "Positionen"-Tab
|
||||||
|
if (this.activeTab !== 'regions') return;
|
||||||
|
if (!this.selectedRegion) return;
|
||||||
|
const bounds = this.$refs.mapImage.getBoundingClientRect();
|
||||||
|
this.startX = event.clientX - bounds.left;
|
||||||
|
this.startY = event.clientY - bounds.top;
|
||||||
|
this.currentX = this.startX;
|
||||||
|
this.currentY = this.startY;
|
||||||
|
this.updateDrawingRect();
|
||||||
|
event.preventDefault();
|
||||||
|
},
|
||||||
|
onMouseMove(event) {
|
||||||
|
// Zeichnen nur im "Positionen"-Tab
|
||||||
|
if (this.activeTab !== 'regions') return;
|
||||||
|
if (this.startX === null || this.startY === null) return;
|
||||||
|
const bounds = this.$refs.mapImage.getBoundingClientRect();
|
||||||
|
this.currentX = event.clientX - bounds.left;
|
||||||
|
this.currentY = event.clientY - bounds.top;
|
||||||
|
this.updateDrawingRect();
|
||||||
|
},
|
||||||
|
onMouseUp() {
|
||||||
|
// Im Entfernungen-Tab niemals Koordinaten verändern
|
||||||
|
if (this.activeTab !== 'regions') {
|
||||||
|
this.startX = null;
|
||||||
|
this.startY = null;
|
||||||
|
this.drawingRect = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.selectedRegion || !this.drawingRect) {
|
||||||
|
this.startX = null;
|
||||||
|
this.startY = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Nur übernehmen, wenn tatsächlich ein Rechteck "aufgezogen" wurde
|
||||||
|
if (this.drawingRect.w < 3 || this.drawingRect.h < 3) {
|
||||||
|
this.drawingRect = null;
|
||||||
|
this.startX = null;
|
||||||
|
this.startY = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Übernehme gezeichnetes Rechteck in ausgewählte Region
|
||||||
|
const bounds = this.$refs.mapImage.getBoundingClientRect();
|
||||||
|
const updatedMap = {
|
||||||
|
x: this.drawingRect.x / bounds.width,
|
||||||
|
y: this.drawingRect.y / bounds.height,
|
||||||
|
w: this.drawingRect.w / bounds.width,
|
||||||
|
h: this.drawingRect.h / bounds.height,
|
||||||
|
};
|
||||||
|
this.selectedRegion.map = updatedMap;
|
||||||
|
this.markRegionDirty(this.selectedRegion.id);
|
||||||
|
this.startX = null;
|
||||||
|
this.startY = null;
|
||||||
|
this.drawingRect = null;
|
||||||
|
},
|
||||||
|
updateDrawingRect() {
|
||||||
|
if (this.startX === null || this.startY === null) return;
|
||||||
|
const x = Math.min(this.startX, this.currentX);
|
||||||
|
const y = Math.min(this.startY, this.currentY);
|
||||||
|
const w = Math.abs(this.currentX - this.startX);
|
||||||
|
const h = Math.abs(this.currentY - this.startY);
|
||||||
|
this.drawingRect = {
|
||||||
|
x: Math.round(x),
|
||||||
|
y: Math.round(y),
|
||||||
|
w: Math.round(w),
|
||||||
|
h: Math.round(h),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
markRegionDirty(id) {
|
||||||
|
if (!this.dirtyRegionIds.includes(id)) {
|
||||||
|
this.dirtyRegionIds.push(id);
|
||||||
|
}
|
||||||
|
if (this.selectedRegion && this.selectedRegion.id === id) {
|
||||||
|
this.selectedRegionDirty = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveRegion(region) {
|
||||||
|
if (!region || !region.map) return;
|
||||||
|
await apiClient.put(`/api/admin/falukant/regions/${region.id}/map`, {
|
||||||
|
map: region.map,
|
||||||
|
});
|
||||||
|
// lokal als nicht mehr dirty markieren
|
||||||
|
this.dirtyRegionIds = this.dirtyRegionIds.filter(id => id !== region.id);
|
||||||
|
if (this.selectedRegion && this.selectedRegion.id === region.id) {
|
||||||
|
this.selectedRegionDirty = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveSelectedRegion() {
|
||||||
|
if (!this.selectedRegion || !this.selectedRegion.map) return;
|
||||||
|
try {
|
||||||
|
await this.saveRegion(this.selectedRegion);
|
||||||
|
// Liste aktualisieren, damit andere Einträge auch aktualisierte Daten sehen
|
||||||
|
await this.loadRegions();
|
||||||
|
const refreshed = this.regions.find(r => r.id === this.selectedRegion.id);
|
||||||
|
if (refreshed) {
|
||||||
|
this.selectedRegion = refreshed;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving region map:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveAllRegions() {
|
||||||
|
if (!this.dirtyRegionIds.length) return;
|
||||||
|
try {
|
||||||
|
const dirtyIds = [...this.dirtyRegionIds];
|
||||||
|
for (const id of dirtyIds) {
|
||||||
|
const region = this.regions.find(r => r.id === id && r.map);
|
||||||
|
if (!region) continue;
|
||||||
|
await this.saveRegion(region);
|
||||||
|
}
|
||||||
|
await this.loadRegions();
|
||||||
|
if (this.selectedRegion) {
|
||||||
|
const refreshed = this.regions.find(r => r.id === this.selectedRegion.id);
|
||||||
|
if (refreshed) {
|
||||||
|
this.selectedRegion = refreshed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving all region maps:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async saveConnection() {
|
||||||
|
try {
|
||||||
|
await apiClient.post('/api/admin/falukant/region-distances', {
|
||||||
|
sourceRegionId: this.newConnection.sourceRegionId,
|
||||||
|
targetRegionId: this.newConnection.targetRegionId,
|
||||||
|
transportMode: this.newConnection.transportMode,
|
||||||
|
distance: this.newConnection.distance,
|
||||||
|
});
|
||||||
|
await this.loadConnections();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving region distance:', error);
|
||||||
|
alert(this.$t('admin.falukant.map.errorSaveConnection'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async deleteConnection(id) {
|
||||||
|
if (!confirm(this.$t('admin.falukant.map.confirmDeleteConnection'))) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/api/admin/falukant/region-distances/${id}`);
|
||||||
|
await this.loadConnections();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting region distance:', error);
|
||||||
|
alert(this.$t('admin.falukant.map.errorDeleteConnection'));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
regionName(id) {
|
||||||
|
const r = this.regions.find(x => x.id === id);
|
||||||
|
return r ? r.name : `#${id}`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.falukant-map-admin {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map {
|
||||||
|
max-width: 800px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-rect {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid rgba(0, 128, 255, 0.7);
|
||||||
|
background-color: rgba(0, 128, 255, 0.2);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-rect.selected {
|
||||||
|
border-color: rgba(255, 128, 0, 0.9);
|
||||||
|
background-color: rgba(255, 128, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-rect.drawing {
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list li {
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list li:nth-child(odd) {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list li.selected {
|
||||||
|
background-color: #e0f0ff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-rect.source,
|
||||||
|
.region-rect.target {
|
||||||
|
border-color: rgba(0, 160, 0, 0.9);
|
||||||
|
background-color: rgba(0, 200, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list li.source,
|
||||||
|
.region-list li.target {
|
||||||
|
background-color: #d2f5d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-list li.dirty {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coords.missing {
|
||||||
|
color: #b00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-form-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-form-table th,
|
||||||
|
.connection-form-table td {
|
||||||
|
padding: 2px 4px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label-cell {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-cell {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.connection-actions-cell {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.mini {
|
||||||
|
padding: 2px 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.mini.icon {
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
@@ -29,7 +29,13 @@
|
|||||||
|
|
||||||
<!-- Inventar / Verkauf -->
|
<!-- Inventar / Verkauf -->
|
||||||
<div v-else-if="activeTab === 'inventory'" class="branch-tab-pane">
|
<div v-else-if="activeTab === 'inventory'" class="branch-tab-pane">
|
||||||
<SaleSection :branchId="selectedBranch.id" ref="saleSection" />
|
<SaleSection
|
||||||
|
:branchId="selectedBranch.id"
|
||||||
|
:vehicles="vehicles"
|
||||||
|
:branches="branches"
|
||||||
|
ref="saleSection"
|
||||||
|
@transportCreated="handleTransportCreated"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Produktion + Produkt-Erträge -->
|
<!-- Produktion + Produkt-Erträge -->
|
||||||
@@ -58,6 +64,48 @@
|
|||||||
<button @click="openBuyVehicleDialog">
|
<button @click="openBuyVehicleDialog">
|
||||||
{{ $t('falukant.branch.transport.buy') }}
|
{{ $t('falukant.branch.transport.buy') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="vehicle-overview" v-if="vehicles && vehicles.length">
|
||||||
|
<table class="vehicle-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ $t('falukant.branch.transport.table.type') }}</th>
|
||||||
|
<th>{{ $t('falukant.branch.transport.table.capacity') }}</th>
|
||||||
|
<th>{{ $t('falukant.branch.transport.table.condition') }}</th>
|
||||||
|
<th>{{ $t('falukant.branch.transport.table.mode') }}</th>
|
||||||
|
<th>{{ $t('falukant.branch.transport.table.speed') }}</th>
|
||||||
|
<th>{{ $t('falukant.branch.transport.table.availableFrom') }}</th>
|
||||||
|
<th>{{ $t('falukant.branch.transport.table.status') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="v in vehicles" :key="v.id">
|
||||||
|
<td>
|
||||||
|
{{ $t(`falukant.branch.vehicles.${v.type.tr}`) }}
|
||||||
|
</td>
|
||||||
|
<td>{{ v.type.capacity }}</td>
|
||||||
|
<td>{{ conditionLabel(v.condition) }}</td>
|
||||||
|
<td>{{ v.type.transportMode }}</td>
|
||||||
|
<td>{{ v.type.speed }}</td>
|
||||||
|
<td>{{ formatDateTime(v.availableFrom) }}</td>
|
||||||
|
<td>
|
||||||
|
<span v-if="v.status === 'travelling'">
|
||||||
|
{{ $t('falukant.branch.transport.status.inUse') }}
|
||||||
|
</span>
|
||||||
|
<span v-else-if="v.status === 'building'">
|
||||||
|
{{ $t('falukant.branch.transport.status.building') }}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t('falukant.branch.transport.status.free') }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p v-else class="no-vehicles">
|
||||||
|
{{ $t('falukant.branch.transport.noVehicles') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BuyVehicleDialog
|
<BuyVehicleDialog
|
||||||
@@ -102,6 +150,7 @@ export default {
|
|||||||
branches: [],
|
branches: [],
|
||||||
selectedBranch: null,
|
selectedBranch: null,
|
||||||
products: [],
|
products: [],
|
||||||
|
vehicles: [],
|
||||||
activeTab: 'production',
|
activeTab: 'production',
|
||||||
tabs: [
|
tabs: [
|
||||||
{ value: 'production', label: 'falukant.branch.tabs.production' },
|
{ value: 'production', label: 'falukant.branch.tabs.production' },
|
||||||
@@ -175,6 +224,7 @@ export default {
|
|||||||
regionId: branch.regionId,
|
regionId: branch.regionId,
|
||||||
cityName: branch.region.name,
|
cityName: branch.region.name,
|
||||||
type: this.$t(`falukant.branch.types.${branch.branchType.labelTr}`),
|
type: this.$t(`falukant.branch.types.${branch.branchType.labelTr}`),
|
||||||
|
branchTypeLabelTr: branch.branchType.labelTr,
|
||||||
isMainBranch: branch.isMainBranch,
|
isMainBranch: branch.isMainBranch,
|
||||||
}));
|
}));
|
||||||
if (!this.selectedBranch) {
|
if (!this.selectedBranch) {
|
||||||
@@ -197,9 +247,11 @@ export default {
|
|||||||
async onBranchSelected(newBranch) {
|
async onBranchSelected(newBranch) {
|
||||||
this.selectedBranch = newBranch;
|
this.selectedBranch = newBranch;
|
||||||
await this.loadProducts();
|
await this.loadProducts();
|
||||||
|
await this.loadVehicles();
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.directorInfo?.refresh();
|
this.$refs.directorInfo?.refresh();
|
||||||
this.$refs.saleSection?.loadInventory();
|
this.$refs.saleSection?.loadInventory();
|
||||||
|
this.$refs.saleSection?.loadTransports();
|
||||||
this.$refs.productionSection?.loadProductions();
|
this.$refs.productionSection?.loadProductions();
|
||||||
this.$refs.storageSection?.loadStorageData();
|
this.$refs.storageSection?.loadStorageData();
|
||||||
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
this.$refs.revenueSection?.refresh && this.$refs.revenueSection.refresh();
|
||||||
@@ -226,14 +278,21 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
upgradeBranch() {
|
async upgradeBranch() {
|
||||||
if (this.selectedBranch) {
|
if (!this.selectedBranch) return;
|
||||||
alert(
|
try {
|
||||||
this.$t(
|
await apiClient.post('/api/falukant/branches/upgrade', {
|
||||||
'falukant.branch.actions.upgradeAlert',
|
branchId: this.selectedBranch.id,
|
||||||
{ branchId: this.selectedBranch.id }
|
});
|
||||||
)
|
await this.loadBranches();
|
||||||
);
|
// Ausgewählten Branch nach dem Upgrade neu setzen
|
||||||
|
const updated = this.branches.find(b => b.id === this.selectedBranch.id);
|
||||||
|
if (updated) {
|
||||||
|
await this.onBranchSelected(updated);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error upgrading branch:', error);
|
||||||
|
alert(this.$t('falukant.branch.actions.upgradeAlert', { branchId: this.selectedBranch.id }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -242,6 +301,9 @@ export default {
|
|||||||
if (main && main !== this.selectedBranch) {
|
if (main && main !== this.selectedBranch) {
|
||||||
this.selectedBranch = main;
|
this.selectedBranch = main;
|
||||||
}
|
}
|
||||||
|
if (this.selectedBranch) {
|
||||||
|
this.loadVehicles();
|
||||||
|
}
|
||||||
if (this.selectedBranch && !this.activeTab) {
|
if (this.selectedBranch && !this.activeTab) {
|
||||||
this.activeTab = 'director';
|
this.activeTab = 'director';
|
||||||
}
|
}
|
||||||
@@ -280,6 +342,38 @@ export default {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
conditionLabel(value) {
|
||||||
|
const v = Number(value) || 0;
|
||||||
|
if (v >= 95) return 'Ausgezeichnet'; // 95–100
|
||||||
|
if (v >= 72) return 'Sehr gut'; // 72–94
|
||||||
|
if (v >= 54) return 'Gut'; // 54–71
|
||||||
|
if (v >= 39) return 'Mäßig'; // 39–53
|
||||||
|
if (v >= 22) return 'Schlecht'; // 22–38
|
||||||
|
if (v >= 6) return 'Sehr schlecht'; // 6–21
|
||||||
|
if (v >= 1) return 'Katastrophal'; // 1–5
|
||||||
|
return 'Unbekannt';
|
||||||
|
},
|
||||||
|
|
||||||
|
async loadVehicles() {
|
||||||
|
if (!this.selectedBranch) return;
|
||||||
|
try {
|
||||||
|
const { data } = await apiClient.get('/api/falukant/vehicles', {
|
||||||
|
params: { regionId: this.selectedBranch.regionId },
|
||||||
|
});
|
||||||
|
this.vehicles = data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading vehicles:', error);
|
||||||
|
this.vehicles = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
formatDateTime(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toLocaleString();
|
||||||
|
},
|
||||||
|
|
||||||
handleEvent(eventData) {
|
handleEvent(eventData) {
|
||||||
switch (eventData.event) {
|
switch (eventData.event) {
|
||||||
case 'production_ready':
|
case 'production_ready':
|
||||||
@@ -352,6 +446,12 @@ export default {
|
|||||||
handleVehiclesBought() {
|
handleVehiclesBought() {
|
||||||
// Refresh status bar (for updated money) and potentially other data later
|
// Refresh status bar (for updated money) and potentially other data later
|
||||||
this.$refs.statusBar?.fetchStatus();
|
this.$refs.statusBar?.fetchStatus();
|
||||||
|
this.loadVehicles();
|
||||||
|
},
|
||||||
|
handleTransportCreated() {
|
||||||
|
this.loadVehicles();
|
||||||
|
this.$refs.storageSection?.loadStorageData();
|
||||||
|
this.$refs.saleSection?.loadTransports();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="(value, key) in status" :key="key">
|
<tr v-for="(value, key) in status" :key="key">
|
||||||
<td>{{ $t(`falukant.house.status.${key}`) }}</td>
|
<td>{{ $t(`falukant.house.status.${key}`) }}</td>
|
||||||
<td>{{ value }}%</td>
|
<td>{{ conditionLabel(value) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button v-if="value < 100" @click="renovate(key)">
|
<button v-if="value < 100" @click="renovate(key)">
|
||||||
{{ $t('falukant.house.renovate') }} ({{ getRenovationCost(key, value) }})
|
{{ $t('falukant.house.renovate') }} ({{ getRenovationCost(key, value) }})
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<tr v-for="(val, prop) in house" :key="prop"
|
<tr v-for="(val, prop) in house" :key="prop"
|
||||||
v-if="['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'].includes(prop)">
|
v-if="['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'].includes(prop)">
|
||||||
<td>{{ $t(`falukant.house.status.${prop}`) }}</td>
|
<td>{{ $t(`falukant.house.status.${prop}`) }}</td>
|
||||||
<td>{{ val }}%</td>
|
<td>{{ conditionLabel(val) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -109,6 +109,17 @@ export default {
|
|||||||
console.error('Error loading house data', err);
|
console.error('Error loading house data', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
conditionLabel(value) {
|
||||||
|
const v = Number(value) || 0;
|
||||||
|
if (v >= 95) return 'Ausgezeichnet'; // 95–100
|
||||||
|
if (v >= 72) return 'Sehr gut'; // 72–94
|
||||||
|
if (v >= 54) return 'Gut'; // 54–71
|
||||||
|
if (v >= 39) return 'Mäßig'; // 39–53
|
||||||
|
if (v >= 22) return 'Schlecht'; // 22–38
|
||||||
|
if (v >= 6) return 'Sehr schlecht'; // 6–21
|
||||||
|
if (v >= 1) return 'Katastrophal'; // 1–5
|
||||||
|
return 'Unbekannt';
|
||||||
|
},
|
||||||
houseStyle(position, picSize) {
|
houseStyle(position, picSize) {
|
||||||
const columns = 3;
|
const columns = 3;
|
||||||
const size = picSize;
|
const size = picSize;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
<span v-if="!isAdvancing">{{ $t('falukant.nobility.advance.confirm') }}</span>
|
<span v-if="!isAdvancing">{{ $t('falukant.nobility.advance.confirm') }}</span>
|
||||||
<span v-else>{{ $t('falukant.nobility.advance.processing') }}</span>
|
<span v-else>{{ $t('falukant.nobility.advance.processing') }}</span>
|
||||||
</button>
|
</button>
|
||||||
<span>->{{ canAdvance }}, {{ isAdvancing }}<-</span>
|
<!-- debug state output removed -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
if (!this.canAdvance || this.isAdvancing) return;
|
if (!this.canAdvance || this.isAdvancing) return;
|
||||||
this.isAdvancing = true;
|
this.isAdvancing = true;
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/api/falukant/nobility/advance');
|
await apiClient.post('/api/falukant/nobility');
|
||||||
await this.loadNobility();
|
await this.loadNobility();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error advancing nobility:', err);
|
console.error('Error advancing nobility:', err);
|
||||||
@@ -124,9 +124,6 @@
|
|||||||
},
|
},
|
||||||
formatCost(val) {
|
formatCost(val) {
|
||||||
return new Intl.NumberFormat(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
return new Intl.NumberFormat(navigator.language, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(val);
|
||||||
},
|
|
||||||
async applyAdvance() {
|
|
||||||
await apiClient.post('/api/falukant/nobility');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user