Some fixes and additions

This commit is contained in:
Torsten Schulz
2025-07-09 14:28:35 +02:00
parent 5029be81e9
commit fceea5b7fb
32 changed files with 4373 additions and 1294 deletions

View File

@@ -48,6 +48,16 @@ class FalukantController {
this.advanceNobility = this.advanceNobility.bind(this); this.advanceNobility = this.advanceNobility.bind(this);
this.getHealth = this.getHealth.bind(this); this.getHealth = this.getHealth.bind(this);
this.healthActivity = this.healthActivity.bind(this); this.healthActivity = this.healthActivity.bind(this);
this.getPoliticsOverview = this.getPoliticsOverview.bind(this);
this.getOpenPolitics = this.getOpenPolitics.bind(this);
this.getElections = this.getElections.bind(this);
this.vote = this.vote.bind(this);
this.getOpenPolitics = this.getOpenPolitics.bind(this);
this.applyForElections = this.applyForElections.bind(this);
this.getRegions = this.getRegions.bind(this);
this.renovate = this.renovate.bind(this);
this.renovateAll = this.renovateAll.bind(this);
this.createBranch = this.createBranch.bind(this);
} }
async getUser(req, res) { async getUser(req, res) {
@@ -117,6 +127,29 @@ class FalukantController {
} }
} }
async createBranch(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const { cityId, branchTypeId } = req.body;
const result = await FalukantService.createBranch(hashedUserId, cityId, branchTypeId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async getBranchTypes(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const result = await FalukantService.getBranchTypes(hashedUserId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async getBranch(req, res) { async getBranch(req, res) {
try { try {
const { userid: hashedUserId } = req.headers; const { userid: hashedUserId } = req.headers;
@@ -325,6 +358,7 @@ class FalukantController {
const result = await FalukantService.convertProposalToDirector(hashedUserId, proposalId); const result = await FalukantService.convertProposalToDirector(hashedUserId, proposalId);
res.status(200).json(result); res.status(200).json(result);
} catch (error) { } catch (error) {
console.log(error.message, error.stack);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
} }
@@ -482,7 +516,6 @@ class FalukantController {
try { try {
const { userid: hashedUserId } = req.headers; const { userid: hashedUserId } = req.headers;
const result = await FalukantService.getUserHouse(hashedUserId); const result = await FalukantService.getUserHouse(hashedUserId);
console.log(result);
res.status(200).json(result); res.status(200).json(result);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@@ -671,6 +704,108 @@ class FalukantController {
console.log(error); console.log(error);
} }
} }
async getPoliticsOverview(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const result = await FalukantService.getPoliticsOverview(hashedUserId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async getOpenPolitics(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const result = await FalukantService.getOpenPolitics(hashedUserId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async getElections(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const result = await FalukantService.getElections(hashedUserId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async vote(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const { votes } = req.body;
const result = await FalukantService.vote(hashedUserId, votes);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async getOpenPolitics(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const result = await FalukantService.getOpenPolitics(hashedUserId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async applyForElections(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const { electionIds } = req.body;
const result = await FalukantService.applyForElections(hashedUserId, electionIds);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async getRegions(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const result = await FalukantService.getRegions(hashedUserId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async renovate(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const { element } = req.body;
const result = await FalukantService.renovate(hashedUserId, element);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
async renovateAll(req, res) {
try {
const { userid: hashedUserId } = req.headers;
const result = await FalukantService.renovateAll(hashedUserId);
res.status(200).json(result);
} catch (error) {
res.status(500).json({ error: error.message });
console.log(error);
}
}
} }
export default FalukantController; export default FalukantController;

View File

@@ -85,6 +85,9 @@ import PoliticalOffice from './falukant/data/political_office.js';
import PoliticalOfficeBenefit from './falukant/predefine/political_office_benefit.js'; import PoliticalOfficeBenefit from './falukant/predefine/political_office_benefit.js';
import PoliticalOfficeBenefitType from './falukant/type/political_office_benefit_type.js'; import PoliticalOfficeBenefitType from './falukant/type/political_office_benefit_type.js';
import PoliticalOfficeRequirement from './falukant/predefine/political_office_prerequisite.js'; import PoliticalOfficeRequirement from './falukant/predefine/political_office_prerequisite.js';
import PoliticalOfficePrerequisite from './falukant/predefine/political_office_prerequisite.js';
import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
import ElectionHistory from './falukant/log/election_history.js';
export default function setupAssociations() { export default function setupAssociations() {
// UserParam related associations // UserParam related associations
@@ -555,9 +558,9 @@ PoliticalOfficeRequirement.belongsTo(PoliticalOfficeType, {
foreignKey: 'characterId', foreignKey: 'characterId',
as: 'holder' as: 'holder'
}); });
FalukantCharacter.hasMany(PoliticalOffice, { FalukantCharacter.hasOne(PoliticalOffice, {
foreignKey: 'characterId', foreignKey: 'characterId',
as: 'heldOffices' as: 'heldOffice'
}); });
// elections // elections
@@ -570,6 +573,16 @@ PoliticalOfficeRequirement.belongsTo(PoliticalOfficeType, {
as: 'elections' as: 'elections'
}); });
Election.belongsTo(RegionData, {
foreignKey: 'regionId',
as: 'region'
});
RegionData.hasMany(Election, {
foreignKey: 'regionId',
as: 'elections'
});
// candidates in an election // candidates in an election
Candidate.belongsTo(Election, { Candidate.belongsTo(Election, {
foreignKey: 'electionId', foreignKey: 'electionId',
@@ -645,4 +658,43 @@ PoliticalOfficeRequirement.belongsTo(PoliticalOfficeType, {
as: 'offices' as: 'offices'
}); });
PoliticalOfficePrerequisite.belongsTo(PoliticalOfficeType, {
foreignKey: 'office_type_id',
as: 'officeTypePrerequisite',
});
PoliticalOfficeType.hasMany(PoliticalOfficePrerequisite, {
foreignKey: 'office_type_id',
as: 'prerequisites',
});
PoliticalOfficeHistory.belongsTo(PoliticalOfficeType, {
foreignKey: 'officeTypeId',
as: 'officeTypeHistory',
});
PoliticalOfficeType.hasMany(PoliticalOfficeHistory, {
foreignKey: 'officeTypeId',
as: 'history',
});
FalukantCharacter.hasMany(PoliticalOfficeHistory, {
foreignKey: 'characterId',
as: 'officeHistories',
});
PoliticalOfficeHistory.belongsTo(FalukantCharacter, {
foreignKey: 'characterId',
as: 'character',
});
ElectionHistory.belongsTo(PoliticalOfficeType, {
foreignKey: 'officeTypeId',
as: 'officeTypeHistory',
});
PoliticalOfficeType.hasMany(ElectionHistory, {
foreignKey: 'officeTypeId',
as: 'electionHistory',
}
)
} }

View File

@@ -30,6 +30,11 @@ ChildRelation.init(
allowNull: false, allowNull: false,
default: false, default: false,
}, },
isHeir: {
type: DataTypes.BOOLEAN,
allowNull: true,
default: false,
}
}, },
{ {
sequelize, sequelize,

View File

@@ -10,15 +10,19 @@ Election.init({
primaryKey: true, primaryKey: true,
autoIncrement: true, autoIncrement: true,
}, },
political_office_id: { officeTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: true,
},
regionId: {
type: DataTypes.INTEGER,
allowNull: false
}, },
date: { date: {
type: DataTypes.DATE, type: DataTypes.DATE,
allowNull: false, allowNull: false,
}, },
posts_to_fill: { postsToFill: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
}, },

View File

@@ -26,6 +26,11 @@ RegionData.init({
key: 'id', key: 'id',
schema: 'falukant_data', schema: 'falukant_data',
} }
},
map: {
type: DataTypes.JSONB,
allowNull: true,
defaultValue: {}
} }
}, { }, {
sequelize, sequelize,

View File

@@ -4,21 +4,18 @@ import { sequelize } from '../../../utils/sequelize.js';
class Vote extends Model {} class Vote extends Model {}
Vote.init({ Vote.init(
{
id: { id: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
primaryKey: true, primaryKey: true,
autoIncrement: true, autoIncrement: true,
}, },
election_id: { electionId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
}, },
voter_character_id: { candidateId: {
type: DataTypes.INTEGER,
allowNull: false,
},
candidate_id: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
}, },
@@ -27,13 +24,25 @@ Vote.init({
allowNull: false, allowNull: false,
defaultValue: DataTypes.NOW, defaultValue: DataTypes.NOW,
}, },
}, { falukantUserId: {
type: DataTypes.INTEGER,
allowNull: false,
},
},
{
sequelize, sequelize,
modelName: 'Vote', modelName: 'Vote',
tableName: 'vote', tableName: 'vote',
schema: 'falukant_data', schema: 'falukant_data',
timestamps: false, timestamps: true,
underscored: true, underscored: true,
}); indexes: [
{
unique: true,
fields: ['election_id', 'candidate_id'],
},
],
}
);
export default Vote; export default Vote;

View File

@@ -0,0 +1,32 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class ElectionHistory extends Model { }
ElectionHistory.init({
electionId: {
type: DataTypes.INTEGER,
allowNull: false,
},
politicalOfficeTypeId: {
type: DataTypes.INTEGER,
allowNull: false,
},
electionDate: {
type: DataTypes.DATE,
allowNull: false,
},
electionResult: {
type: DataTypes.JSON,
allowNull: false,
}
}, {
sequelize,
modelName: 'ElectionHistory',
tableName: 'election_history',
schema: 'falukant_log',
timestamps: true,
underscored: true,
});
export default ElectionHistory;

View File

@@ -0,0 +1,35 @@
import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js';
class PoliticalOfficeHistory extends Model { }
PoliticalOfficeHistory.init(
{
characterId: {
type: DataTypes.INTEGER,
allowNull: false,
},
officeTypeId: {
type: DataTypes.INTEGER,
allowNull: false,
},
startDate: {
type: DataTypes.DATE,
allowNull: false,
},
endDate: {
type: DataTypes.DATE,
allowNull: false,
}
},
{
sequelize,
modelName: 'PoliticalOfficeHistory',
tableName: 'political_office_history',
schema: 'falukant_log',
timestamps: true,
underscored: true,
}
);
export default PoliticalOfficeHistory;

View File

@@ -1,4 +1,5 @@
// falukant/predefine/political_office_prerequisite.js // models/falukant/predefine/political_office_prerequisite.js
import { Model, DataTypes } from 'sequelize'; import { Model, DataTypes } from 'sequelize';
import { sequelize } from '../../../utils/sequelize.js'; import { sequelize } from '../../../utils/sequelize.js';
@@ -10,10 +11,13 @@ PoliticalOfficePrerequisite.init({
primaryKey: true, primaryKey: true,
autoIncrement: true, autoIncrement: true,
}, },
political_office_id: {
// Neu: Feld heißt jetzt eindeutig "office_type_id"
office_type_id: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
}, },
prerequisite: { prerequisite: {
type: DataTypes.JSONB, type: DataTypes.JSONB,
allowNull: false, allowNull: false,

View File

@@ -94,6 +94,8 @@ import Election from './falukant/data/election.js';
import Candidate from './falukant/data/candidate.js'; import Candidate from './falukant/data/candidate.js';
import Vote from './falukant/data/vote.js'; import Vote from './falukant/data/vote.js';
import ElectionResult from './falukant/data/election_result.js'; import ElectionResult from './falukant/data/election_result.js';
import PoliticalOfficeHistory from './falukant/log/political_office_history.js';
import ElectionHistory from './falukant/log/election_history.js';
const models = { const models = {
SettingsType, SettingsType,
@@ -191,6 +193,8 @@ const models = {
Candidate, Candidate,
Vote, Vote,
ElectionResult, ElectionResult,
PoliticalOfficeHistory,
ElectionHistory,
}; };
export default models; export default models;

View File

@@ -241,6 +241,180 @@ export async function createTriggers() {
$$ LANGUAGE plpgsql VOLATILE; $$ LANGUAGE plpgsql VOLATILE;
`; `;
// process_electionsStored-Procedure anlegen
const createProcessElectionsFunction = `
CREATE OR REPLACE FUNCTION falukant_data.process_elections()
RETURNS TABLE (
office_id INTEGER,
office_type_id INTEGER,
character_id INTEGER,
region_id INTEGER
)
AS $$
BEGIN
RETURN QUERY
WITH
-- 1) Alle Wahlen, die vor mindestens 3 Tagen erstellt wurden
to_process AS (
SELECT
e.id AS election_id,
e.office_type_id AS tp_office_type_id,
e.region_id AS tp_region_id,
e.posts_to_fill AS tp_posts_to_fill,
e.date AS tp_election_date
FROM falukant_data.election e
WHERE (e.created_at::date + INTERVAL '3 days') <= NOW()::date
),
-- 2) Stimmen pro Kandidat zählen
votes AS (
SELECT
tp.election_id,
tp.tp_posts_to_fill AS posts_to_fill,
c.character_id,
COUNT(v.*) AS votes_received
FROM to_process tp
JOIN falukant_data.candidate c
ON c.election_id = tp.election_id
LEFT JOIN falukant_data.vote v
ON v.election_id = c.election_id
AND v.candidate_id = c.id
GROUP BY tp.election_id, tp.tp_posts_to_fill, c.character_id
),
-- 3) Ranking nach Stimmen
ranked AS (
SELECT
v.election_id,
v.character_id,
v.votes_received,
ROW_NUMBER() OVER (
PARTITION BY v.election_id
ORDER BY v.votes_received DESC, RANDOM()
) AS rn
FROM votes v
),
-- 4) Top-N (posts_to_fill) sind Gewinner
winners AS (
SELECT
r.election_id,
r.character_id
FROM ranked r
JOIN to_process tp
ON tp.election_id = r.election_id
WHERE r.rn <= tp.tp_posts_to_fill
),
-- 5) Verbleibende Kandidaten ohne Gewinner
remaining AS (
SELECT
tp.election_id,
c.character_id
FROM to_process tp
JOIN falukant_data.candidate c
ON c.election_id = tp.election_id
WHERE c.character_id NOT IN (
SELECT w.character_id
FROM winners w
WHERE w.election_id = tp.election_id
)
),
-- 6) Zufalls-Nachrücker bis alle Plätze gefüllt sind
random_fill AS (
SELECT
rp.election_id,
rp.character_id
FROM remaining rp
JOIN to_process tp
ON tp.election_id = rp.election_id
JOIN LATERAL (
SELECT r2.character_id
FROM remaining r2
WHERE r2.election_id = rp.election_id
ORDER BY RANDOM()
LIMIT GREATEST(
0,
tp.tp_posts_to_fill
- (SELECT COUNT(*) FROM winners w2 WHERE w2.election_id = tp.election_id)
)
) sub
ON sub.character_id = rp.character_id
),
-- 7) Finale Gewinner (Winners random_fill)
final_winners AS (
SELECT * FROM winners
UNION ALL
SELECT * FROM random_fill
),
-- 8) Neue Ämter anlegen und sofort zurückliefern
created_offices AS (
INSERT INTO falukant_data.political_office
(office_type_id, character_id, created_at, updated_at, region_id)
SELECT
tp.tp_office_type_id,
fw.character_id,
NOW() AS created_at,
NOW() AS updated_at,
tp.tp_region_id
FROM final_winners fw
JOIN to_process tp
ON tp.election_id = fw.election_id
RETURNING
id AS co_office_id,
falukant_data.political_office.office_type_id AS co_office_type_id,
falukant_data.political_office.character_id AS co_character_id,
falukant_data.political_office.region_id AS co_region_id
),
-- 9) election_history befüllen
_hist AS (
INSERT INTO falukant_log.election_history
(election_id, political_office_type_id, election_date, election_result, created_at, updated_at)
SELECT
tp.election_id,
tp.tp_office_type_id,
tp.tp_election_date,
(
SELECT json_agg(vr)
FROM votes vr
WHERE vr.election_id = tp.election_id
),
NOW() AS created_at,
NOW() AS updated_at
FROM to_process tp
),
-- 10) Cleanup: Stimmen, Kandidaten und Wahlen löschen
_del_votes AS (
DELETE FROM falukant_data.vote
WHERE election_id IN (SELECT election_id FROM to_process)
),
_del_candidates AS (
DELETE FROM falukant_data.candidate
WHERE election_id IN (SELECT election_id FROM to_process)
),
_del_elections AS (
DELETE FROM falukant_data.election
WHERE id IN (SELECT election_id FROM to_process)
)
-- 11) Ergebnis wirklich zurückliefern
SELECT
co.co_office_id AS office_id,
co.co_office_type_id,
co.co_character_id,
co.co_region_id
FROM created_offices co
ORDER BY co.co_region_id, co.co_office_id;
END;
$$ LANGUAGE plpgsql;
`;
try { try {
await sequelize.query(createTriggerFunction); await sequelize.query(createTriggerFunction);
await sequelize.query(createInsertTrigger); await sequelize.query(createInsertTrigger);
@@ -257,6 +431,7 @@ export async function createTriggers() {
await sequelize.query(createChildRelationNameFunction); await sequelize.query(createChildRelationNameFunction);
await sequelize.query(createChildRelationNameTrigger); await sequelize.query(createChildRelationNameTrigger);
await sequelize.query(createRandomMoodUpdateMethod); await sequelize.query(createRandomMoodUpdateMethod);
await sequelize.query(createProcessElectionsFunction);
await initializeCharacterTraitTrigger(); await initializeCharacterTraitTrigger();
console.log('Triggers created successfully'); console.log('Triggers created successfully');

View File

@@ -11,8 +11,10 @@ router.get('/character/affect', falukantController.getCharacterAffect);
router.get('/name/randomfirstname/:gender', falukantController.randomFirstName); router.get('/name/randomfirstname/:gender', falukantController.randomFirstName);
router.get('/name/randomlastname', falukantController.randomLastName); router.get('/name/randomlastname', falukantController.randomLastName);
router.get('/info', falukantController.getInfo); router.get('/info', falukantController.getInfo);
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.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);
@@ -43,6 +45,8 @@ router.get('/nobility/titels', falukantController.getTitelsOfNobility);
router.get('/houses/types', falukantController.getHouseTypes); router.get('/houses/types', falukantController.getHouseTypes);
router.get('/houses/buyable', falukantController.getBuyableHouses); router.get('/houses/buyable', falukantController.getBuyableHouses);
router.get('/houses', falukantController.getUserHouse); router.get('/houses', falukantController.getUserHouse);
router.post('/houses/renovate-all', falukantController.renovateAll);
router.post('/houses/renovate', falukantController.renovate);
router.post('/houses', falukantController.buyUserHouse); router.post('/houses', falukantController.buyUserHouse);
router.get('/party/types', falukantController.getPartyTypes); router.get('/party/types', falukantController.getPartyTypes);
router.post('/party', falukantController.createParty); router.post('/party', falukantController.createParty);
@@ -57,5 +61,13 @@ router.post('/bank/credits', falukantController.takeBankCredits);
router.get('/nobility', falukantController.getNobility); router.get('/nobility', falukantController.getNobility);
router.post('/nobility', falukantController.advanceNobility); router.post('/nobility', falukantController.advanceNobility);
router.get('/health', falukantController.getHealth); router.get('/health', falukantController.getHealth);
router.post('/health', falukantController.healthActivity) router.post('/health', falukantController.healthActivity);
router.get('/politics/overview', falukantController.getPoliticsOverview);
router.get('/politics/open', falukantController.getOpenPolitics);
router.get('/politics/elections', falukantController.getElections);
router.post('/politics/elections', falukantController.vote);
router.get('/politics/open', falukantController.getOpenPolitics);
router.post('/politics/open', falukantController.applyForElections);
router.get('/cities', falukantController.getRegions);
export default router; export default router;

View File

@@ -1,6 +1,6 @@
import BaseService from './BaseService.js'; import BaseService from './BaseService.js';
import { Sequelize, Op, where } from 'sequelize'; import { Sequelize, Op } from 'sequelize';
import { sequelize } from '../utils/sequelize.js';
import FalukantPredefineFirstname from '../models/falukant/predefine/firstname.js'; import FalukantPredefineFirstname from '../models/falukant/predefine/firstname.js';
import FalukantPredefineLastname from '../models/falukant/predefine/lastname.js'; import FalukantPredefineLastname from '../models/falukant/predefine/lastname.js';
import FalukantUser from '../models/falukant/data/user.js'; import FalukantUser from '../models/falukant/data/user.js';
@@ -47,6 +47,12 @@ import LearnRecipient from '../models/falukant/type/learn_recipient.js';
import Credit from '../models/falukant/data/credit.js'; import Credit from '../models/falukant/data/credit.js';
import TitleRequirement from '../models/falukant/type/title_requirement.js'; import TitleRequirement from '../models/falukant/type/title_requirement.js';
import HealthActivity from '../models/falukant/log/health_activity.js'; import HealthActivity from '../models/falukant/log/health_activity.js';
import Election from '../models/falukant/data/election.js';
import PoliticalOfficeType from '../models/falukant/type/political_office_type.js';
import Candidate from '../models/falukant/data/candidate.js';
import Vote from '../models/falukant/data/vote.js';
import PoliticalOfficePrerequisite from '../models/falukant/predefine/political_office_prerequisite.js';
import PoliticalOfficeHistory from '../models/falukant/log/political_office_history.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);
@@ -103,6 +109,26 @@ class FalukantService extends BaseService {
{ tr: "drunkOfLife", method: "healthDruckOfLife", cost: 5000000 } { tr: "drunkOfLife", method: "healthDruckOfLife", cost: 5000000 }
]; ];
static RECURSIVE_REGION_SEARCH = `
WITH RECURSIVE ancestors AS (
SELECT
r.id,
r.parent_id
FROM falukant_data.region r
join falukant_data."character" c
on c.region_id = r.id
WHERE c.user_id = :user_id
UNION ALL
SELECT
r.id,
r.parent_id
FROM falukant_data.region r
JOIN ancestors a ON r.id = a.parent_id
)
SELECT id
FROM ancestors;
`;
async getFalukantUserByHashedId(hashedId) { async getFalukantUserByHashedId(hashedId) {
const user = await FalukantUser.findOne({ const user = await FalukantUser.findOne({
include: [ include: [
@@ -168,9 +194,21 @@ class FalukantService extends BaseService {
attributes: ['name'] attributes: ['name']
} }
] ]
} },
{
model: UserHouse,
as: 'userHouse',
include: [
{
model: HouseType,
as: 'houseType',
'attributes': ['labelTr', 'position']
},
], ],
attributes: ['money', 'creditAmount', 'todayCreditTaken'] attributes: ['roofCondition'],
},
],
attributes: ['money', 'creditAmount', 'todayCreditTaken',]
}); });
if (!u) throw new Error('User not found'); if (!u) throw new Error('User not found');
if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate)); if (u.character?.birthdate) u.character.setDataValue('age', calcAge(u.character.birthdate));
@@ -277,6 +315,38 @@ class FalukantService extends BaseService {
return bs.map(b => ({ ...b.toJSON(), isMainBranch: u.mainBranchRegionId === b.regionId })); return bs.map(b => ({ ...b.toJSON(), isMainBranch: u.mainBranchRegionId === b.regionId }));
} }
async createBranch(hashedUserId, cityId, branchTypeId) {
const user = await getFalukantUserOrFail(hashedUserId);
const branchType = await BranchType.findByPk(branchTypeId);
if (!branchType) {
throw new Error(`Unknown branchTypeId ${branchTypeId}`);
}
const existingCount = await Branch.count({
where: { falukantUserId: user.id }
});
const exponentBase = Math.max(existingCount, 1);
const rawCost = branchType.baseCost * Math.pow(exponentBase, 1.2);
const cost = Math.round(rawCost * 100) / 100;
await updateFalukantUserMoney(
user.id,
-cost,
'create_branch'
);
const branch = await Branch.create({
branchTypeId,
regionId: cityId,
falukantUserId: user.id
});
return branch;
}
async getBranchTypes(hashedUserId) {
const user = await getFalukantUserOrFail(hashedUserId);
const branchTypes = await BranchType.findAll();
return branchTypes;
}
async getBranch(hashedUserId, branchId) { async getBranch(hashedUserId, branchId) {
const u = await getFalukantUserOrFail(hashedUserId); const u = await getFalukantUserOrFail(hashedUserId);
const br = await Branch.findOne({ const br = await Branch.findOne({
@@ -318,6 +388,8 @@ class FalukantService extends BaseService {
const runningProductions = await Production.findAll({ where: { branchId: b.id } }); const runningProductions = await Production.findAll({ where: { branchId: b.id } });
if (runningProductions.length >= 2) { if (runningProductions.length >= 2) {
throw new Error('Too many productions'); throw new Error('Too many productions');
return; // wird später implementiert, wenn familie implementiert ist.
} }
if (!p) throw new Error('Product not found'); if (!p) throw new Error('Product not found');
quantity = Math.min(100, quantity); quantity = Math.min(100, quantity);
@@ -822,9 +894,12 @@ class FalukantService extends BaseService {
await this.deleteExpiredProposals(); await this.deleteExpiredProposals();
const existingProposals = await this.fetchProposals(falukantUserId, regionId); const existingProposals = await this.fetchProposals(falukantUserId, regionId);
if (existingProposals.length > 0) { if (existingProposals.length > 0) {
console.log('Existing proposals:', existingProposals);
return this.formatProposals(existingProposals); return this.formatProposals(existingProposals);
} }
console.log('No existing proposals, generating new ones');
await this.generateProposals(falukantUserId, regionId); await this.generateProposals(falukantUserId, regionId);
console.log('Fetch new proposals');
const newProposals = await this.fetchProposals(falukantUserId, regionId); const newProposals = await this.fetchProposals(falukantUserId, regionId);
return this.formatProposals(newProposals); return this.formatProposals(newProposals);
} }
@@ -867,13 +942,14 @@ class FalukantService extends BaseService {
} }
async generateProposals(falukantUserId, regionId) { async generateProposals(falukantUserId, regionId) {
try {
const threeWeeksAgo = new Date(Date.now() - 21 * 24 * 60 * 60 * 1000);
const proposalCount = Math.floor(Math.random() * 3) + 3; const proposalCount = Math.floor(Math.random() * 3) + 3;
for (let i = 0; i < proposalCount; i++) { for (let i = 0; i < proposalCount; i++) {
const directorCharacter = await FalukantCharacter.findOne({ const directorCharacter = await FalukantCharacter.findOne({
where: { where: {
regionId, regionId,
createdAt: { createdAt: { [Op.lt]: threeWeeksAgo },
[Op.lt]: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000),
}, },
include: [ include: [
{ {
@@ -881,9 +957,8 @@ class FalukantService extends BaseService {
as: 'nobleTitle', as: 'nobleTitle',
attributes: ['level'], attributes: ['level'],
}, },
] ],
}, order: sequelize.literal('RANDOM()'),
order: Sequelize.fn('RANDOM'),
}); });
if (!directorCharacter) { if (!directorCharacter) {
throw new Error('No directors available for the region'); throw new Error('No directors available for the region');
@@ -898,6 +973,10 @@ class FalukantService extends BaseService {
proposedIncome, proposedIncome,
}); });
} }
} catch (error) {
console.log(error.message, error.stack);
throw new Error(error.message);
}
} }
async calculateAverageKnowledge(characterId) { async calculateAverageKnowledge(characterId) {
@@ -1394,39 +1473,65 @@ class FalukantService extends BaseService {
} }
async getGifts(hashedUserId) { async getGifts(hashedUserId) {
// 1) Mein User & Character
const user = await this.getFalukantUserByHashedId(hashedUserId); const user = await this.getFalukantUserByHashedId(hashedUserId);
const character = await FalukantCharacter.findOne({ const myChar = await FalukantCharacter.findOne({ where: { userId: user.id } });
where: { userId: user.id }, if (!myChar) throw new Error('Character not found');
// 2) Beziehung finden und „anderen“ Character bestimmen
const rel = await Relationship.findOne({
where: {
[Op.or]: [
{ character1Id: myChar.id },
{ character2Id: myChar.id }
]
},
include: [
{ model: FalukantCharacter, as: 'character1', include: [{ model: CharacterTrait, as: 'traits' }] },
{ model: FalukantCharacter, as: 'character2', include: [{ model: CharacterTrait, as: 'traits' }] }
]
}); });
if (!character) { if (!rel) throw new Error('Beziehung nicht gefunden');
throw new Error('Character not found');
} const relatedChar = rel.character1.id === myChar.id ? rel.character2 : rel.character1;
let gifts = await PromotionalGift.findAll({
// 3) Trait-IDs und Mood des relatedChar
const relatedTraitIds = relatedChar.traits.map(t => t.id);
const relatedMoodId = relatedChar.moodId;
// 4) Gifts laden aber nur die passenden Moods und Traits als Unter-Arrays
const gifts = await PromotionalGift.findAll({
include: [ include: [
{ {
model: PromotionalGiftMood, model: PromotionalGiftMood,
as: 'promotionalgiftmoods', as: 'promotionalgiftmoods',
attributes: ['mood_id', 'suitability'] attributes: ['mood_id', 'suitability'],
where: { mood_id: relatedMoodId },
required: false // Gifts ohne Mood-Match bleiben erhalten, haben dann leeres Array
}, },
{ {
model: PromotionalGiftCharacterTrait, model: PromotionalGiftCharacterTrait,
as: 'characterTraits', as: 'characterTraits',
attributes: ['trait_id', 'suitability'] attributes: ['trait_id', 'suitability'],
where: { trait_id: relatedTraitIds },
required: false // Gifts ohne Trait-Match bleiben erhalten
} }
] ]
}); });
const lowestTitleOfNobility = await TitleOfNobility.findOne({
order: [['id', 'ASC']], // 5) Rest wie gehabt: Kosten berechnen und zurückgeben
}); const lowestTitleOfNobility = await TitleOfNobility.findOne({ order: [['id', 'ASC']] });
return await Promise.all(gifts.map(async (gift) => { return Promise.all(gifts.map(async gift => ({
return {
id: gift.id, id: gift.id,
name: gift.name, name: gift.name,
cost: await this.getGiftCost(gift.value, character.titleOfNobility, lowestTitleOfNobility.id), cost: await this.getGiftCost(
moodsAffects: gift.promotionalgiftmoods, gift.value,
charactersAffects: gift.characterTraits, myChar.titleOfNobility,
}; lowestTitleOfNobility.id
})); ),
moodsAffects: gift.promotionalgiftmoods, // nur Einträge mit relatedMoodId
charactersAffects: gift.characterTraits // nur Einträge mit relatedTraitIds
})));
} }
async getChildren(hashedUserId) { async getChildren(hashedUserId) {
@@ -2282,6 +2387,398 @@ class FalukantService extends BaseService {
const raw = Math.floor(Math.random() * 26); const raw = Math.floor(Math.random() * 26);
return this.healthChange(user, raw); return this.healthChange(user, raw);
} }
async getPoliticsOverview(hashedUserId) {
}
async getOpenPolitics(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user || user.character.nobleTitle.labelTr === 'noncivil') {
return [];
}
}
async getElections(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user || user.character.nobleTitle.labelTr === 'noncivil') {
return [];
}
const rows = await sequelize.query(
FalukantService.RECURSIVE_REGION_SEARCH,
{
replacements: { user_id: user.id },
type: sequelize.QueryTypes.SELECT
}
);
const regionIds = rows.map(r => r.id);
// 3) Zeitbereich "heute"
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date();
todayEnd.setHours(23, 59, 59, 999);
// 4) Wahlen laden (inkl. Kandidaten, Stimmen und Verknüpfungen)
const rawElections = await Election.findAll({
where: {
regionId: { [Op.in]: regionIds },
date: { [Op.between]: [todayStart, todayEnd] }
},
include: [
{
model: RegionData,
as: 'region',
attributes: ['name'],
include: [{
model: RegionType,
as: 'regionType',
attributes: ['labelTr']
}]
},
{
model: PoliticalOfficeType,
as: 'officeType',
attributes: ['name']
},
{
model: Candidate,
as: 'candidates',
attributes: ['id'],
include: [{
model: FalukantCharacter,
as: 'character',
attributes: ['birthdate', 'gender'],
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }
]
}]
},
{
model: Vote,
as: 'votes',
attributes: ['candidateId'],
where: {
falukantUserId: user.id
},
required: false
}
]
});
return rawElections.map(election => {
const e = election.get({ plain: true });
const voted = Array.isArray(e.votes) && e.votes.length > 0;
const reducedCandidates = (e.candidates || []).map(cand => {
const ch = cand.character || {};
const firstname = ch.definedFirstName?.name || '';
const lastname = ch.definedLastName?.name || '';
return {
id: cand.id,
title: ch.nobleTitle?.labelTr || null,
name: `${firstname} ${lastname}`.trim(),
age: calcAge(ch.birthdate),
gender: ch.gender
};
});
return {
id: e.id,
officeType: { name: e.officeType.name },
region: {
name: e.region.name,
regionType: { labelTr: e.region.regionType.labelTr }
},
date: e.date,
postsToFill: e.postsToFill,
candidates: reducedCandidates,
voted: voted,
votedFor: voted ? e.votes.map(vote => { return vote.candidateId }) : null,
};
});
}
async vote(hashedUserId, votes) {
const elections = await this.getElections(hashedUserId);
if (!Array.isArray(elections) || elections.length === 0) {
throw new Error('No elections found');
}
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user) {
throw new Error('User not found');
}
const validElections = votes.filter(voteEntry => {
const e = elections.find(el => el.id === voteEntry.electionId);
return e && !e.voted;
});
if (validElections.length === 0) {
throw new Error('No valid elections to vote for (either nonexistent or already voted)');
}
validElections.forEach(voteEntry => {
const e = elections.find(el => el.id === voteEntry.electionId);
const allowedIds = e.candidates.map(c => c.id);
voteEntry.candidateIds.forEach(cid => {
if (!allowedIds.includes(cid)) {
throw new Error(`Candidate ID ${cid} is not valid for election ${e.id}`);
}
});
if (voteEntry.candidateIds.length > e.postsToFill) {
throw new Error(`Too many candidates selected for election ${e.id}. Allowed: ${e.postsToFill}`);
}
});
return await sequelize.transaction(async (tx) => {
const toCreate = [];
validElections.forEach(voteEntry => {
voteEntry.candidateIds.forEach(candidateId => {
toCreate.push({
electionId: voteEntry.electionId,
candidateId,
falukantUserId: user.id
});
});
});
await Vote.bulkCreate(toCreate, {
transaction: tx,
ignoreDuplicates: true,
returning: false
});
return { success: true };
});
}
async getOpenPolitics(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
const characterId = user.character.id;
const rows = await sequelize.query(
FalukantService.RECURSIVE_REGION_SEARCH,
{
replacements: { user_id: user.id },
type: sequelize.QueryTypes.SELECT
}
);
const regionIds = rows.map(r => r.id);
const histories = await PoliticalOfficeHistory.findAll({
where: { characterId },
attributes: ['officeTypeId', 'startDate', 'endDate']
});
const heldOfficeTypeIds = histories.map(h => h.officeTypeId);
const allTypes = await PoliticalOfficeType.findAll({ attributes: ['id', 'name'] });
const nameToId = Object.fromEntries(allTypes.map(t => [t.name, t.id]));
const openPositions = await Election.findAll({
where: {
regionId: { [Op.in]: regionIds },
date: { [Op.lt]: new Date() }
},
include: [
{
model: RegionData,
as: 'region',
attributes: ['name'],
include: [
{ model: RegionType, as: 'regionType', attributes: ['labelTr'] }
]
},
{ model: Candidate, as: 'candidates' },
{
model: PoliticalOfficeType, as: 'officeType',
include: [{ model: PoliticalOfficePrerequisite, as: 'prerequisites' }]
}
]
});
const result = openPositions
.filter(election => {
const prereqs = election.officeType.prerequisites || [];
return prereqs.some(pr => {
const jobs = pr.prerequisite.jobs;
if (!Array.isArray(jobs) || jobs.length === 0) return true;
return jobs.some(jobName => {
const reqId = nameToId[jobName];
return heldOfficeTypeIds.includes(reqId);
});
});
})
.map(election => {
const e = election.get({ plain: true });
const jobs = e.officeType.prerequisites[0]?.prerequisite.jobs || [];
const matchingHistory = histories
.filter(h => jobs.includes(allTypes.find(t => t.id === h.officeTypeId)?.name))
.map(h => ({
officeTypeId: h.officeTypeId,
startDate: h.startDate,
endDate: h.endDate
}));
return {
...e,
history: matchingHistory
};
});
return result;
}
async applyForElections(hashedUserId, electionIds) {
// 1) Hole FalukantUser + Character
const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user) {
throw new Error('User nicht gefunden');
}
const character = user.character;
if (!character) {
throw new Error('Kein Charakter zum User gefunden');
}
// 2) NoncivilTitel aussperren
if (character.nobleTitle.labelTr === 'noncivil') {
return { applied: [], skipped: electionIds };
}
// 3) Ermittle die heute offenen Wahlen, auf die er zugreifen darf
// (getElections liefert id, officeType, region, date, postsToFill, candidates, voted…)
const openElections = await this.getElections(hashedUserId);
const allowedIds = new Set(openElections.map(e => e.id));
// 4) Filter alle electionIds auf gültige/erlaubte
const toTry = electionIds.filter(id => allowedIds.has(id));
if (toTry.length === 0) {
return { applied: [], skipped: electionIds };
}
// 5) Prüfe, auf welche dieser Wahlen der Character bereits als Candidate eingetragen ist
const existing = await Candidate.findAll({
where: {
electionId: { [Op.in]: toTry },
characterId: character.id
},
attributes: ['electionId']
});
const alreadyIds = new Set(existing.map(c => c.electionId));
// 6) Erstelle Liste der Wahlen, für die er sich noch nicht beworben hat
const newApplications = toTry.filter(id => !alreadyIds.has(id));
const skipped = electionIds.filter(id => !newApplications.includes(id));
console.log(newApplications, skipped);
// 7) Bulk-Insert aller neuen Bewerbungen
if (newApplications.length > 0) {
const toInsert = newApplications.map(eid => ({
electionId: eid,
characterId: character.id
}));
await Candidate.bulkCreate(toInsert);
}
return {
applied: newApplications,
skipped: skipped
};
}
async getRegions(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
const regions = await RegionData.findAll({
attributes: ['id', 'name', 'map'],
include: [
{
model: RegionType,
as: 'regionType',
where: {
labelTr: 'city'
},
attributes: ['labelTr']
},
{
model: Branch,
as: 'branches',
where: {
falukantUserId: user.id
},
include: [
{
model: BranchType,
as: 'branchType',
attributes: ['labelTr'],
},
],
attributes: ['branchTypeId'],
required: false,
}
]
});
return regions;
}
async renovate(hashedUserId, element) {
const user = await getFalukantUserOrFail(hashedUserId);
const house = await UserHouse.findOne({
where: { userId: user.id },
include: [{ model: HouseType, as: 'houseType' }]
});
if (!house) throw new Error('House not found');
const oldValue = house[element];
if (oldValue >= 100) {
return { cost: 0 };
}
const baseCost = house.houseType?.cost || 0;
const cost = this._calculateRenovationCost(baseCost, element, oldValue);
house[element] = 100;
await house.save();
await updateFalukantUserMoney(
user.id,
-cost,
`renovation_${element}`
);
return { cost };
}
_calculateRenovationCost(baseCost, key, currentVal) {
const weights = {
roofCondition: 0.25,
wallCondition: 0.25,
floorCondition: 0.25,
windowCondition: 0.25
};
const weight = weights[key] || 0;
const missing = 100 - currentVal;
const raw = (missing / 100) * baseCost * weight;
return Math.round(raw * 100) / 100;
}
async renovateAll(hashedUserId) {
const user = await getFalukantUserOrFail(hashedUserId);
const house = await UserHouse.findOne({
where: { userId: user.id },
include: [{ model: HouseType, as: 'houseType' }]
});
if (!house) throw new Error('House not found');
const baseCost = house.houseType?.cost || 0;
const keys = ['roofCondition', 'wallCondition', 'floorCondition', 'windowCondition'];
let rawSum = 0;
for (const key of keys) {
const current = house[key];
if (current < 100) {
rawSum += this._calculateRenovationCost(baseCost, key, current);
}
}
const totalCost = Math.round(rawSum * 0.8 * 100) / 100;
for (const key of keys) {
house[key] = 100;
}
await house.save();
await updateFalukantUserMoney(
user.id,
-totalCost,
'renovation_all'
);
return { cost: totalCost };
}
} }
export default new FalukantService(); export default new FalukantService();

View File

@@ -12,6 +12,8 @@ import PartyType from "../../models/falukant/type/party.js";
import MusicType from "../../models/falukant/type/music.js"; import MusicType from "../../models/falukant/type/music.js";
import BanquetteType from "../../models/falukant/type/banquette.js"; import BanquetteType from "../../models/falukant/type/banquette.js";
import LearnRecipient from "../../models/falukant/type/learn_recipient.js"; import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js";
export const initializeFalukantTypes = async () => { export const initializeFalukantTypes = async () => {
await initializeFalukantTypeRegions(); await initializeFalukantTypeRegions();
@@ -25,6 +27,8 @@ export const initializeFalukantTypes = async () => {
await initializeFalukantMusicTypes(); await initializeFalukantMusicTypes();
await initializeFalukantBanquetteTypes(); await initializeFalukantBanquetteTypes();
await initializeLearnerTypes(); await initializeLearnerTypes();
await initializePoliticalOfficeTypes();
await initializePoliticalOfficePrerequisites();
}; };
const regionTypes = []; const regionTypes = [];
@@ -39,14 +43,15 @@ const regionTypeTrs = [
const regions = [ const regions = [
{ labelTr: "falukant", regionType: "country", parentTr: null }, { labelTr: "falukant", regionType: "country", parentTr: null },
{ labelTr: "duchy1", regionType: "duchy", parentTr: "falukant" }, { labelTr: "Hessen", regionType: "duchy", parentTr: "falukant" },
{ labelTr: "markgravate", regionType: "markgravate", parentTr: "duchy1" }, { labelTr: "Groß-Benbach", regionType: "markgravate", parentTr: "Hessen" },
{ labelTr: "shire1", regionType: "shire", parentTr: "markgravate" }, { labelTr: "Siebenbachen", regionType: "shire", parentTr: "Groß-Benbach" },
{ labelTr: "county1", regionType: "county", parentTr: "shire1" }, { labelTr: "Bad Homburg", regionType: "county", parentTr: "Siebenbachen" },
{ labelTr: "town1", regionType: "city", parentTr: "county1" }, { labelTr: "Maintal", regionType: "county", parentTr: "Siebenbachen" },
{ labelTr: "town2", regionType: "city", parentTr: "county1" }, { labelTr: "Frankfurt", regionType: "city", parentTr: "Bad Homburg", map: {x: 187, y: 117, w: 10, h:11} },
{ labelTr: "town3", regionType: "city", parentTr: "county1" }, { labelTr: "Oberursel", regionType: "city", parentTr: "Bad Homburg", map: {x: 168, y: 121, w: 10, h:11} },
{ labelTr: "town4", regionType: "city", parentTr: "county1" }, { labelTr: "Offenbach", regionType: "city", parentTr: "Bad Homburg", map: {x: 171, y: 142, w: 10, h:11} },
{ labelTr: "Königstein", regionType: "city", parentTr: "Maintal", map: {x: 207, y: 124, w: 24, h:18} },
]; ];
const relationships = [ const relationships = [
@@ -256,6 +261,282 @@ const learnerTypes = [
{ tr: 'director', }, { tr: 'director', },
]; ];
const politicalOffices = [
{ tr: "assessor", seatsPerRegion: 10, regionType: "city", termLength: 5 },
{ tr: "councillor", seatsPerRegion: 7, regionType: "city", termLength: 7 },
{ tr: "council", seatsPerRegion: 5, regionType: "city", termLength: 4 },
{ tr: "beadle", seatsPerRegion: 1, regionType: "city", termLength: 6 },
{ tr: "town-clerk", seatsPerRegion: 3, regionType: "city", termLength: 10 },
{ tr: "mayor", seatsPerRegion: 1, regionType: "city", termLength: 3 },
{ tr: "master-builder", seatsPerRegion: 10, regionType: "county", termLength: 10 },
{ tr: "village-major", seatsPerRegion: 6, regionType: "county", termLength: 5 },
{ tr: "judge", seatsPerRegion: 3, regionType: "county", termLength: 8 },
{ tr: "bailif", seatsPerRegion: 1, regionType: "county", termLength: 4 },
{ tr: "taxman", seatsPerRegion: 8, regionType: "shire", termLength: 5 },
{ tr: "sheriff", seatsPerRegion: 5, regionType: "shire", termLength: 8 },
{ tr: "consultant", seatsPerRegion: 3, regionType: "shire", termLength: 9 },
{ tr: "treasurer", seatsPerRegion: 1, regionType: "shire", termLength: 7 },
{ tr: "hangman", seatsPerRegion: 9, regionType: "markgravate", termLength: 5 },
{ tr: "territorial-council", seatsPerRegion: 6, regionType: "markgravate", termLength: 6 },
{ tr: "territorial-council-speaker", seatsPerRegion: 4, regionType: "markgravate", termLength: 8 },
{ tr: "ruler-consultant", seatsPerRegion: 1, regionType: "markgravate", termLength: 3 },
{ tr: "state-administrator", seatsPerRegion: 7, regionType: "duchy", termLength: 3 },
{ tr: "super-state-administrator", seatsPerRegion: 5, regionType: "duchy", termLength: 6 },
{ tr: "governor", seatsPerRegion: 1, regionType: "duchy", termLength: 5 },
{ tr: "ministry-helper", seatsPerRegion: 12, regionType: "country", termLength: 4 },
{ tr: "minister", seatsPerRegion: 3, regionType: "country", termLength: 4 },
{ tr: "chancellor", seatsPerRegion: 1, regionType: "country", termLength: 4 }
];
const politicalOfficePrerequisites = [
{
officeTr: "assessor",
prerequisite: {
titles: [
"civil", "sir", "townlord", "by", "landlord", "knight",
"baron", "count", "palsgrave", "margrave", "landgrave",
"ruler", "elector", "imperial-prince", "duke", "grand-duke",
"prince-regent", "king"
]
}
},
{
officeTr: "councillor",
prerequisite: {
titles: [
"civil", "sir", "townlord", "by", "landlord", "knight",
"baron", "count", "palsgrave", "margrave", "landgrave",
"ruler", "elector", "imperial-prince", "duke", "grand-duke",
"prince-regent", "king"
],
jobs: ["assessor"]
}
},
{
officeTr: "council",
prerequisite: {
titles: [
"civil", "sir", "townlord", "by", "landlord", "knight",
"baron", "count", "palsgrave", "margrave", "landgrave",
"ruler", "elector", "imperial-prince", "duke", "grand-duke",
"prince-regent", "king"
],
jobs: ["councillor"]
}
},
{
officeTr: "beadle",
prerequisite: {
titles: [
"sir", "townlord", "by", "landlord", "knight", "baron",
"count", "palsgrave", "margrave", "landgrave", "ruler",
"elector", "imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["council"]
}
},
{
officeTr: "town-clerk",
prerequisite: {
titles: [
"sir", "townlord", "by", "landlord", "knight", "baron",
"count", "palsgrave", "margrave", "landgrave", "ruler",
"elector", "imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["council"]
}
},
{
officeTr: "mayor",
prerequisite: {
titles: [
"townlord", "by", "landlord", "knight", "baron", "count",
"palsgrave", "margrave", "landgrave", "ruler", "elector",
"imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["beadle", "town-clerk"]
}
},
{
officeTr: "master-builder",
prerequisite: {
titles: [
"by", "landlord", "knight", "baron", "count", "palsgrave",
"margrave", "landgrave", "ruler", "elector", "imperial-prince",
"duke", "grand-duke", "prince-regent", "king"
],
jobs: ["mayor"]
}
},
{
officeTr: "village-major",
prerequisite: {
titles: [
"by", "landlord", "knight", "baron", "count", "palsgrave",
"margrave", "landgrave", "ruler", "elector", "imperial-prince",
"duke", "grand-duke", "prince-regent", "king"
],
jobs: ["master-builder"]
}
},
{
officeTr: "judge",
prerequisite: {
titles: [
"landlord", "knight", "baron", "count", "palsgrave", "margrave",
"landgrave", "ruler", "elector", "imperial-prince", "duke",
"grand-duke", "prince-regent", "king"
],
jobs: ["village-major"]
}
},
{
officeTr: "bailif",
prerequisite: {
titles: [
"landlord", "knight", "baron", "count", "palsgrave", "margrave",
"landgrave", "ruler", "elector", "imperial-prince", "duke",
"grand-duke", "prince-regent", "king"
],
jobs: ["judge"]
}
},
{
officeTr: "taxman",
prerequisite: {
titles: [
"knight", "baron", "count", "palsgrave", "margrave", "landgrave",
"ruler", "elector", "imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["bailif"]
}
},
{
officeTr: "sheriff",
prerequisite: {
titles: [
"baron", "count", "palsgrave", "margrave", "landgrave", "ruler",
"elector", "imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["taxman"]
}
},
{
officeTr: "consultant",
prerequisite: {
titles: [
"baron", "count", "palsgrave", "margrave", "landgrave", "ruler",
"elector", "imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["sheriff"]
}
},
{
officeTr: "treasurer",
prerequisite: {
titles: [
"count", "palsgrave", "margrave", "landgrave", "ruler", "elector",
"imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["consultant"]
}
},
{
officeTr: "hangman",
prerequisite: {
titles: [
"count", "palsgrave", "margrave", "landgrave", "ruler", "elector",
"imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["consultant"]
}
},
{
officeTr: "territorial-council",
prerequisite: {
titles: [
"palsgrave", "margrave", "landgrave", "ruler", "elector", "imperial-prince",
"duke", "grand-duke", "prince-regent", "king"
],
jobs: ["treasurer", "hangman"]
}
},
{
officeTr: "territorial-council-speaker",
prerequisite: {
titles: [
"margrave", "landgrave", "ruler", "elector", "imperial-prince", "duke",
"grand-duke", "prince-regent", "king"
],
jobs: ["territorial-council"]
}
},
{
officeTr: "ruler-consultant",
prerequisite: {
titles: [
"landgrave", "ruler", "elector", "imperial-prince", "duke", "grand-duke",
"prince-regent", "king"
],
jobs: ["territorial-council-speaker"]
}
},
{
officeTr: "state-administrator",
prerequisite: {
titles: [
"ruler", "elector", "imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["ruler-consultant"]
}
},
{
officeTr: "super-state-administrator",
prerequisite: {
titles: [
"elector", "imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["state-administrator"]
}
},
{
officeTr: "governor",
prerequisite: {
titles: [
"imperial-prince", "duke", "grand-duke", "prince-regent", "king"
],
jobs: ["super-state-administrator"]
}
},
{
officeTr: "ministry-helper",
prerequisite: {
titles: [
"grand-duke", "prince-regent", "king"
],
jobs: ["governor"]
}
},
{
officeTr: "minister",
prerequisite: {
titles: [
"prince-regent", "king"
],
jobs: ["ministry-helper"]
}
},
{
officeTr: "chancellor",
prerequisite: {
titles: [
"prince-regent", "king"
],
jobs: ["minister"]
}
}
];
{ {
const giftNames = promotionalGifts.map(g => g.name); const giftNames = promotionalGifts.map(g => g.name);
const traitNames = characterTraits.map(t => t.name); const traitNames = characterTraits.map(t => t.name);
@@ -330,7 +611,8 @@ export const initializeFalukantRegions = async () => {
where: { name: region.labelTr }, where: { name: region.labelTr },
defaults: { defaults: {
regionTypeId: regionType.id, regionTypeId: regionType.id,
parentId: parentRegion?.id || null parentId: parentRegion?.id || null,
map: parentRegion?.map || {},
} }
}); });
} }
@@ -479,3 +761,36 @@ export const initializeLearnerTypes = async () => {
}); });
} }
} }
export const initializePoliticalOfficeTypes = async () => {
for (const po of politicalOffices) {
await PoliticalOfficeType.findOrCreate({
where: { name: po.tr },
defaults: {
seatsPerRegion: po.seatsPerRegion,
regionType: po.regionType,
termLength: po.termLength
}
});
}
};
export const initializePoliticalOfficePrerequisites = async () => {
for (const prereq of politicalOfficePrerequisites) {
// zunächst den OfficeType anhand seines Namens (tr) ermitteln
const office = await PoliticalOfficeType.findOne({
where: { name: prereq.officeTr }
});
if (!office) continue;
// Nun findOrCreate mit dem neuen Spaltennamen:
await PoliticalOfficePrerequisite.findOrCreate({
where: { office_type_id: office.id },
defaults: {
office_type_id: office.id,
prerequisite: prereq.prerequisite
}
});
}
};

BIN
dump.rdb

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -8,13 +8,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tinymce/tinymce-vue": "^6.0.1", "@tiptap/starter-kit": "^2.14.0",
"@tiptap/vue-3": "^2.14.0",
"axios": "^1.7.2", "axios": "^1.7.2",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tinymce": "^7.3.0",
"vue": "~3.4.31", "vue": "~3.4.31",
"vue-i18n": "^10.0.0-beta.2", "vue-i18n": "^10.0.0-beta.2",
"vue-multiselect": "^3.1.0", "vue-multiselect": "^3.1.0",
@@ -30,6 +30,6 @@
"sass": "^1.77.8", "sass": "^1.77.8",
"stream-browserify": "^3.0.0", "stream-browserify": "^3.0.0",
"util": "^0.12.5", "util": "^0.12.5",
"vite": "^5.4.4" "vite": "^6.3.5"
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

View File

@@ -4,7 +4,7 @@
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.value" :key="tab.value"
:class="['simple-tab', { active: internalValue === tab.value }]" :class="['simple-tab', { active: internalValue === tab.value }]"
@click="$emit('update:modelValue', tab.value)" @click="selectTab(tab.value)"
> >
<slot name="label" :tab="tab"> <slot name="label" :tab="tab">
{{ $t(tab.label) }} {{ $t(tab.label) }}
@@ -30,6 +30,14 @@
internalValue() { internalValue() {
return this.modelValue; return this.modelValue;
} }
},
methods: {
selectTab(value) {
// 1) v-model aktualisieren
this.$emit('update:modelValue', value);
// 2) zusätzliches change-Event
this.$emit('change', value);
}
} }
}; };
</script> </script>
@@ -53,4 +61,3 @@
color: #000; color: #000;
} }
</style> </style>

View File

@@ -11,19 +11,36 @@
/> />
</div> </div>
<div> <div>
<button @click="$emit('createBranch')">{{ $t('falukant.branch.actions.create') }}</button> <button @click="openCreateBranchDialog">
<button @click="$emit('upgradeBranch')" :disabled="!localSelectedBranch"> {{ $t('falukant.branch.actions.create') }}
</button>
<button
@click="$emit('upgradeBranch')"
:disabled="!localSelectedBranch"
>
{{ $t('falukant.branch.actions.upgrade') }} {{ $t('falukant.branch.actions.upgrade') }}
</button> </button>
</div> </div>
</div> </div>
<!-- Dialog-Komponente -->
<CreateBranchDialog
ref="createBranchDialog"
:regions="availableRegions"
@create-branch="handleCreateBranch"
/>
</template> </template>
<script> <script>
import FormattedDropdown from '@/components/form/FormattedDropdown.vue'; import FormattedDropdown from '@/components/form/FormattedDropdown.vue';
import CreateBranchDialog from '@/dialogues/falukant/CreateBranchDialog.vue';
export default { export default {
name: "BranchSelection", name: "BranchSelection",
components: { FormattedDropdown }, components: {
FormattedDropdown,
CreateBranchDialog,
},
props: { props: {
branches: { type: Array, required: true }, branches: { type: Array, required: true },
selectedBranch: { type: Object, default: null }, selectedBranch: { type: Object, default: null },
@@ -42,10 +59,20 @@
this.localSelectedBranch = newVal; this.localSelectedBranch = newVal;
}, },
}, },
methods: { methods: {
updateSelectedBranch(value) { updateSelectedBranch(value) {
this.$emit('branchSelected', value); this.$emit('branchSelected', value);
}, },
openCreateBranchDialog() {
this.$refs.createBranchDialog.open();
},
handleCreateBranch() {
// wird ausgelöst, sobald der Dialog onConfirm erfolgreich abschließt
this.$emit('createBranch');
},
}, },
}; };
</script> </script>
@@ -57,8 +84,8 @@
border-radius: 4px; border-radius: 4px;
padding: 10px; padding: 10px;
} }
button { button {
margin: 5px; margin: 5px;
} }
</style> </style>

View File

@@ -6,7 +6,7 @@
<p>{{ contact.message }}</p> <p>{{ contact.message }}</p>
</div> </div>
<div class="editor-container"> <div class="editor-container">
<Editor v-model="answer" :init="tinymceInitOptions" :api-key="apiKey" /> <EditorContent :editor="editor" class="editor" />
</div> </div>
</DialogWidget> </DialogWidget>
@@ -19,8 +19,8 @@
</template> </template>
<script> <script>
import { ref, onBeforeUnmount } from 'vue' import { Editor, EditorContent } from '@tiptap/vue-3'
import Editor from '@tinymce/tinymce-vue' import StarterKit from '@tiptap/starter-kit'
import apiClient from '@/utils/axios.js' import apiClient from '@/utils/axios.js'
import DialogWidget from '@/components/DialogWidget.vue' import DialogWidget from '@/components/DialogWidget.vue'
@@ -28,29 +28,15 @@ export default {
name: 'AnswerContact', name: 'AnswerContact',
components: { components: {
DialogWidget, DialogWidget,
Editor, EditorContent,
}, },
data() { data() {
return { return {
apiKey: import.meta.env.VITE_TINYMCE_API_KEY,
dialog: null, dialog: null,
errorDialog: null, errorDialog: null,
contact: null, contact: null,
answer: '',
errorMessage: '', errorMessage: '',
tinymceInitOptions: { editor: null,
height: 300,
menubar: false,
plugins: [
'advlist autolink lists link image charmap print preview anchor',
'searchreplace visualblocks code fullscreen',
'insertdatetime media table paste code help wordcount'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor fontfamily fontsize | \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | help'
},
buttons: [ buttons: [
{ text: 'OK', action: this.sendAnswer }, { text: 'OK', action: this.sendAnswer },
{ text: 'Cancel', action: this.closeDialog } { text: 'Cancel', action: this.closeDialog }
@@ -64,24 +50,25 @@ export default {
open(contactData) { open(contactData) {
this.contact = contactData; this.contact = contactData;
this.dialog.open(); this.dialog.open();
this.answer = ''; if (this.editor) this.editor.commands.setContent('');
}, },
closeDialog() { closeDialog() {
this.dialog.close(); this.dialog.close();
this.answer = ''; if (this.editor) this.editor.commands.clearContent();
}, },
closeErrorDialog() { closeErrorDialog() {
this.errorDialog.close(); this.errorDialog.close();
}, },
async sendAnswer() { async sendAnswer() {
const answer = this.editor ? this.editor.getHTML() : '';
try { try {
await apiClient.post('/api/admin/contacts/answer', { await apiClient.post('/api/admin/contacts/answer', {
id: this.contact.id, id: this.contact.id,
answer: this.answer, answer,
}); });
this.dialog.close(); this.dialog.close();
this.$emit('refresh'); this.$emit('refresh');
this.answer = ''; if (this.editor) this.editor.commands.clearContent();
} catch (error) { } catch (error) {
const errorText = error.response?.data?.error || 'An unexpected error occurred.'; const errorText = error.response?.data?.error || 'An unexpected error occurred.';
this.errorMessage = errorText; this.errorMessage = errorText;
@@ -92,9 +79,16 @@ export default {
mounted() { mounted() {
this.dialog = this.$refs.dialog; this.dialog = this.$refs.dialog;
this.errorDialog = this.$refs.errorDialog; this.errorDialog = this.$refs.errorDialog;
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
}, },
beforeUnmount() { beforeUnmount() {
// Aufräumarbeiten falls nötig if (this.editor) {
this.editor.destroy();
}
} }
} }
</script> </script>
@@ -106,5 +100,13 @@ export default {
.editor-container { .editor-container {
margin-top: 20px; margin-top: 20px;
border: 1px solid #ccc;
padding: 10px;
min-height: 200px;
}
.editor {
min-height: 150px;
outline: none;
} }
</style> </style>

View File

@@ -0,0 +1,342 @@
<template>
<DialogWidget
ref="dialog"
name="create-branch"
:title="$t('falukant.branch.actions.create')"
icon="branch.png"
showClose
:buttons="dialogButtons"
@close="onClose"
>
<div class="create-branch-form">
<div class="map-wrapper">
<!-- linke Spalte: Karte + Regionen + Dev-Draw -->
<div class="map-container">
<img
ref="mapImage"
src="/images/falukant/map.png"
class="map"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp"
@dragstart.prevent
/>
<div
v-for="city in cities"
:key="city.name"
class="city-region"
:class="city.branches.length > 0 ? 'has-branch' : 'clickable'"
:style="{
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)"
:title="city.name"
></div>
<div
v-if="devMode && rect"
class="dev-rect"
:style="{
top: rect.y + 'px',
left: rect.x + 'px',
width: rect.width + 'px',
height: rect.height + 'px'
}"
></div>
</div>
<!-- rechte Spalte: Dev-Info + Auswahl -->
<div class="sidebar">
<div v-if="devMode" class="dev-info">
<span class="dev-badge">DEV MODE</span>
<span v-if="rect" class="dev-label-outside">
{{ rect.x }},{{ rect.y }} {{ rect.width }}×{{ rect.height }}
</span>
</div>
<div v-if="selectedRegion" class="selected-region-wrapper">
<div class="selected-region">
{{ $t('falukant.branch.selection.selected') }}:
<strong>{{ selectedRegion.name }}</strong>
</div>
<label class="form-label">
{{ $t('falukant.branch.columns.type') }}
<select v-model="selectedType" class="form-control">
<option
v-for="type in branchTypes"
:key="type.id"
:value="type.id"
>
{{ $t(`falukant.branch.types.${type.labelTr}`) }}
({{ formatCost(computeBranchCost(type)) }})
</option>
</select>
</label>
</div>
</div>
</div>
</div>
</DialogWidget>
</template>
<script>
import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js';
export default {
name: 'CreateBranchDialog',
components: { DialogWidget },
data() {
return {
cities: [],
branchTypes: [],
selectedRegion: null,
selectedType: null,
devMode: false,
rect: null,
startX: null,
startY: null,
currentX: 0,
currentY: 0,
};
},
computed: {
dialogButtons() {
return [
{ text: this.$t('Cancel'), action: this.close },
{ text: this.$t('falukant.branch.actions.create'), action: this.onConfirm },
];
},
},
async mounted() {
window.addEventListener('keydown', this.onKeyDown);
await Promise.all([
this.loadCities(),
this.loadBranchTypes(),
]);
this.selectedType = this.branchTypes.length ? this.branchTypes[0].id : null;
},
beforeDestroy() {
window.removeEventListener('keydown', this.onKeyDown);
},
methods: {
open() {
this.$refs.dialog.open();
},
close() {
this.$refs.dialog.close();
},
async onConfirm() {
if (!this.selectedRegion || !this.selectedType) return;
try {
await apiClient.post('/api/falukant/branches', {
cityId: this.selectedRegion.id,
branchTypeId: this.selectedType,
});
this.$emit('create-branch');
this.close();
} catch (e) {
}
},
onClose() {
this.close();
this.$emit('close');
},
onKeyDown(e) {
if (e.ctrlKey && e.altKey && e.code === 'KeyD') {
this.devMode = !this.devMode;
if (!this.devMode) this.rect = null;
}
},
onMouseDown(e) {
if (!this.devMode) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.startX = e.clientX - bounds.left;
this.startY = e.clientY - bounds.top;
this.currentX = this.startX;
this.currentY = this.startY;
this.updateRect();
e.preventDefault();
},
onMouseMove(e) {
if (!this.devMode || this.startX === null) return;
const bounds = this.$refs.mapImage.getBoundingClientRect();
this.currentX = e.clientX - bounds.left;
this.currentY = e.clientY - bounds.top;
this.updateRect();
},
onMouseUp() {
if (!this.devMode) return;
this.startX = null;
this.startY = null;
},
updateRect() {
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 width = Math.abs(this.currentX - this.startX);
const height = Math.abs(this.currentY - this.startY);
this.rect = {
x: Math.round(x),
y: Math.round(y),
width: Math.round(width),
height: Math.round(height),
};
},
async loadCities() {
const { data } = await apiClient.get('/api/falukant/cities');
this.cities = data;
},
onCityClick(city) {
this.selectedRegion = city;
},
async loadBranchTypes() {
const { data } = await apiClient.get('/api/falukant/branches/types');
this.branchTypes = data;
},
computeBranchCost(type) {
const total = this.cities.reduce((sum, city) => sum + city.branches.length, 0);
const factor = Math.pow(Math.max(total, 1), 1.2);
const raw = type.baseCost * factor;
return Math.round(raw * 100) / 100;
},
formatCost(value) {
return new Intl.NumberFormat(navigator.language, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
},
},
};
</script>
<style scoped>
.create-branch-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.map-wrapper {
display: flex;
align-items: flex-start;
gap: 1rem;
}
.map-container {
position: relative;
width: fit-content;
}
.map {
max-width: 500px;
max-height: 400px;
user-select: none;
cursor: crosshair;
}
.city-region {
position: absolute;
}
.city-region.clickable {
cursor: pointer;
background: rgba(0, 0, 255, 0.2);
}
.city-region.has-branch {
cursor: default;
background: transparent;
border: 2px solid #00ff00;
}
.dev-rect {
position: absolute;
border: 2px dashed red;
pointer-events: none;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
min-width: 200px;
}
.dev-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dev-badge {
background: rgba(255, 0, 0, 0.7);
color: white;
padding: 2px 6px;
font-size: 0.75rem;
border-radius: 3px;
pointer-events: none;
}
.dev-label-outside {
background: rgba(0, 0, 0, 0.7);
color: #fff;
font-size: 0.75rem;
padding: 2px 4px;
border-radius: 2px;
pointer-events: none;
}
.selected-region-wrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected-region {
font-weight: 600;
}
.form-label {
font-weight: 600;
margin-bottom: 0.25rem;
}
.form-control {
padding: 0.5rem;
font-size: 1rem;
border: 1px solid #ccc;
border-radius: 4px;
}
</style>

View File

@@ -51,8 +51,8 @@ export default {
selectedProposal: null, selectedProposal: null,
products: [], products: [],
buttons: [ buttons: [
{ text: 'Einstellen', action: this.hireDirector }, { text: this.$t('falukant.newdirector.hire'), action: this.hireDirector },
{ text: 'Abbrechen', action: 'close' }, { text: this.$t('Cancel'), action: 'close' },
], ],
}; };
}, },

View File

@@ -55,8 +55,7 @@
<img :src="imagePreview" alt="Image Preview" <img :src="imagePreview" alt="Image Preview"
style="max-width: 100px; max-height: 100px;" /> style="max-width: 100px; max-height: 100px;" />
</div> </div>
<editor v-model="newEntryContent" :init="tinymceInitOptions" :api-key="apiKey" <EditorContent :editor="editor" class="editor" />
tinymce-script-src="/tinymce/tinymce.min.js"></editor>
</div> </div>
<button @click="submitGuestbookEntry">{{ $t('socialnetwork.profile.guestbook.submit') <button @click="submitGuestbookEntry">{{ $t('socialnetwork.profile.guestbook.submit')
}}</button> }}</button>
@@ -95,14 +94,15 @@
import DialogWidget from '@/components/DialogWidget.vue'; import DialogWidget from '@/components/DialogWidget.vue';
import apiClient from '@/utils/axios.js'; import apiClient from '@/utils/axios.js';
import FolderItem from '../../components/FolderItem.vue'; import FolderItem from '../../components/FolderItem.vue';
import TinyMCEEditor from '@tinymce/tinymce-vue'; import { Editor, EditorContent } from '@tiptap/vue-3'
import StarterKit from '@tiptap/starter-kit'
export default { export default {
name: 'UserProfileDialog', name: 'UserProfileDialog',
components: { components: {
DialogWidget, DialogWidget,
FolderItem, FolderItem,
editor: TinyMCEEditor, EditorContent,
}, },
data() { data() {
return { return {
@@ -126,27 +126,20 @@ export default {
{ name: 'guestbook', label: this.$t('socialnetwork.profile.tab.guestbook') } { name: 'guestbook', label: this.$t('socialnetwork.profile.tab.guestbook') }
], ],
apiKey: import.meta.env.VITE_TINYMCE_API_KEY, apiKey: import.meta.env.VITE_TINYMCE_API_KEY,
tinymceInitOptions: { editor: null,
script_url: '/tinymce/tinymce.min.js',
height: 300,
menubar: true,
plugins: [
'lists', 'link',
'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'table'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor backcolor fontfamily fontsize| \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | link visualblocks code',
contextmenu: 'link image table',
menubar: 'edit format table',
promotion: false,
},
hasSendFriendshipRequest: false, hasSendFriendshipRequest: false,
friendshipState: 'none', friendshipState: 'none',
}; };
}, },
mounted: async function () {
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
},
beforeUnmount: function () {
if (this.editor) this.editor.destroy();
},
methods: { methods: {
open() { open() {
this.$refs.dialog.open(); this.$refs.dialog.open();

View File

@@ -4,7 +4,8 @@
"age": "Alter", "age": "Alter",
"wealth": "Vermögen", "wealth": "Vermögen",
"health": "Gesundheit", "health": "Gesundheit",
"events": "Ereignisse" "events": "Ereignisse",
"relationship": "Beziehung"
}, },
"health": { "health": {
"amazing": "Super", "amazing": "Super",
@@ -97,7 +98,8 @@
"selection": { "selection": {
"title": "Niederlassungsauswahl", "title": "Niederlassungsauswahl",
"selected": "Ausgewählte Niederlassung", "selected": "Ausgewählte Niederlassung",
"placeholder": "Noch keine Niederlassung ausgewählt" "placeholder": "Noch keine Niederlassung ausgewählt",
"selectedcity": "Ausgewählte Stadt"
}, },
"actions": { "actions": {
"create": "Neue Niederlassung erstellen", "create": "Neue Niederlassung erstellen",
@@ -223,7 +225,6 @@
"3": "Mittel", "3": "Mittel",
"4": "Hoch", "4": "Hoch",
"5": "Sehr hoch" "5": "Sehr hoch"
}, },
"mood": "Stimmung", "mood": "Stimmung",
"progress": "Zuneigung", "progress": "Zuneigung",
@@ -363,9 +364,22 @@
"happy": "Glücklich", "happy": "Glücklich",
"sad": "Traurig", "sad": "Traurig",
"angry": "Wütend", "angry": "Wütend",
"scared": "Verängstigt", "nervous": "Nervös",
"surprised": "Überrascht", "excited": "Aufgeregt",
"normal": "Normal" "bored": "Gelangweilt",
"fearful": "Ängstlich",
"confident": "Selbstbewusst",
"curious": "Neugierig",
"hopeful": "Hoffnungsvoll",
"frustrated": "Frustriert",
"lonely": "Einsam",
"grateful": "Dankbar",
"jealous": "Eifersüchtig",
"guilty": "Schuldig",
"apathetic": "Apathisch",
"relieved": "Erleichtert",
"proud": "Stolz",
"ashamed": "Beschämt"
}, },
"character": { "character": {
"brave": "Mutig", "brave": "Mutig",
@@ -595,6 +609,72 @@
"barber": "Barbier" "barber": "Barbier"
}, },
"choose": "Bitte auswählen" "choose": "Bitte auswählen"
},
"politics": {
"title": "Politik",
"tabs": {
"current": "Aktuelle Position",
"upcoming": "Anstehende Neuwahl-Positionen",
"elections": "Wahlen"
},
"current": {
"office": "Amt",
"region": "Region",
"termEnds": "Läuft ab am",
"income": "Einkommen",
"none": "Keine aktuelle Position vorhanden.",
"holder": "Inhaber"
},
"open": {
"office": "Amt",
"region": "Region",
"date": "Datum",
"candidacy": "Kandidatur",
"none": "Keine offenen Positionen."
},
"upcoming": {
"office": "Amt",
"region": "Region",
"postDate": "Datum",
"none": "Keine anstehenden Positionen."
},
"elections": {
"office": "Amt",
"region": "Region",
"date": "Datum",
"posts": "Zu besetzende Posten",
"none": "Keine Wahlen vorhanden.",
"choose": "Kandidaten",
"vote": "Stimme abgeben",
"voteAll": "Alle Stimmen abgeben",
"candidates": "Kandidaten",
"action": "Aktion"
},
"offices": {
"chancellor": "Kanzler",
"minister": "Minister",
"ministry-helper": "Ministerhelfer",
"governor": "Gouverneur",
"super-state-administrator": "Oberstaatsverwalter",
"state-administrator": "Staatsverwalter",
"ruler-consultant": "Berater des Herrschers",
"territorial-council-speaker": "Sprecher des Territorialrats",
"territorial-council": "Territorialrat",
"hangman": "Henker",
"treasurer": "Schatzmeister",
"sheriff": "Sheriff",
"taxman": "Steuereintreiber",
"bailif": "Gerichtsdiener",
"judge": "Richter",
"village-major": "Dorfvorsteher",
"master-builder": "Baumeister",
"mayor": "Bürgermeister",
"town-clerk": "Stadtschreiber",
"beadle": "Schulze",
"council": "Ratsherr",
"councillor": "Stadtrat",
"assessor": "Schätzer"
}
} }
} }
} }

View File

@@ -11,6 +11,7 @@ import EducationView from '../views/falukant/EducationView.vue';
import BankView from '../views/falukant/BankView.vue'; import BankView from '../views/falukant/BankView.vue';
import DirectorView from '../views/falukant/DirectorView.vue'; import DirectorView from '../views/falukant/DirectorView.vue';
import HealthView from '../views/falukant/HealthView.vue'; import HealthView from '../views/falukant/HealthView.vue';
import PoliticsView from '../views/falukant/PoliticsView.vue';
const falukantRoutes = [ const falukantRoutes = [
{ {
@@ -91,6 +92,12 @@ const falukantRoutes = [
component: HealthView, component: HealthView,
meta: { requiresAuth: true } meta: { requiresAuth: true }
}, },
{
path: '/falukant/politics',
name: 'PoliticsView',
component: PoliticsView,
meta: { requiresAuth: true }
},
]; ];
export default falukantRoutes; export default falukantRoutes;

View File

@@ -3,16 +3,48 @@
<StatusBar ref="statusBar" /> <StatusBar ref="statusBar" />
<div class="contentscroll"> <div class="contentscroll">
<h2>{{ $t('falukant.branch.title') }}</h2> <h2>{{ $t('falukant.branch.title') }}</h2>
<BranchSelection :branches="branches" :selectedBranch="selectedBranch" @branchSelected="onBranchSelected"
@createBranch="createBranch" @upgradeBranch="upgradeBranch" ref="branchSelection" /> <BranchSelection
<DirectorInfo v-if="selectedBranch" :branchId="selectedBranch.id" ref="directorInfo" /> :branches="branches"
<SaleSection v-if="selectedBranch" :branchId="selectedBranch.id" ref="saleSection" /> :selectedBranch="selectedBranch"
<ProductionSection v-if="selectedBranch" :branchId="selectedBranch.id" :products="products" @branchSelected="onBranchSelected"
ref="productionSection" /> @createBranch="createBranch"
<StorageSection v-if="selectedBranch" :branchId="selectedBranch.id" ref="storageSection" /> @upgradeBranch="upgradeBranch"
<RevenueSection v-if="selectedBranch" :products="products" ref="branchSelection"
:calculateProductRevenue="calculateProductRevenue" :calculateProductProfit="calculateProductProfit" />
ref="revenueSection" />
<DirectorInfo
v-if="selectedBranch"
:branchId="selectedBranch.id"
ref="directorInfo"
/>
<SaleSection
v-if="selectedBranch"
:branchId="selectedBranch.id"
ref="saleSection"
/>
<ProductionSection
v-if="selectedBranch"
:branchId="selectedBranch.id"
:products="products"
ref="productionSection"
/>
<StorageSection
v-if="selectedBranch"
:branchId="selectedBranch.id"
ref="storageSection"
/>
<RevenueSection
v-if="selectedBranch"
:products="products"
:calculateProductRevenue="calculateProductRevenue"
:calculateProductProfit="calculateProductProfit"
ref="revenueSection"
/>
</div> </div>
</div> </div>
</template> </template>
@@ -39,6 +71,7 @@ export default {
StorageSection, StorageSection,
RevenueSection, RevenueSection,
}, },
data() { data() {
return { return {
branches: [], branches: [],
@@ -46,42 +79,32 @@ export default {
products: [], products: [],
}; };
}, },
computed: { computed: {
...mapState(['socket', 'daemonSocket']), ...mapState(['socket', 'daemonSocket']),
}, },
async mounted() { async mounted() {
await this.loadBranches(); await this.loadBranches();
const branchId = this.$route.params.branchId; const branchId = this.$route.params.branchId;
await this.loadProducts(); await this.loadProducts();
if (branchId) { if (branchId) {
this.selectedBranch = this.selectedBranch = this.branches.find(
this.branches.find(branch => branch.id === parseInt(branchId)) || null; b => b.id === parseInt(branchId, 10)
) || null;
} else { } else {
this.selectMainBranch(); this.selectMainBranch();
} }
const events = [
"production_ready", // Daemon-Socket
"stock_change",
"price_update",
"director_death",
"production_started",
"selled_items",
"falukantUpdateStatus",
"falukantBranchUpdate",
];
if (this.daemonSocket) { if (this.daemonSocket) {
this.daemonSocket.addEventListener('message', this.handleDaemonMessage); this.daemonSocket.addEventListener('message', this.handleDaemonMessage);
} }
events.forEach(eventName => {
if (this.socket) { // Live-Socket-Events
this.socket.on(eventName, (data) => { [
this.handleEvent({ event: eventName, ...data });
});
}
});
},
beforeUnmount() {
const events = [
"production_ready", "production_ready",
"stock_change", "stock_change",
"price_update", "price_update",
@@ -90,16 +113,36 @@ export default {
"selled_items", "selled_items",
"falukantUpdateStatus", "falukantUpdateStatus",
"falukantBranchUpdate", "falukantBranchUpdate",
]; "knowledge_update"
events.forEach(eventName => { ].forEach(eventName => {
if (this.socket) {
this.socket.on(eventName, data => this.handleEvent({ event: eventName, ...data }));
}
});
},
beforeUnmount() {
[
"production_ready",
"stock_change",
"price_update",
"director_death",
"production_started",
"selled_items",
"falukantUpdateStatus",
"falukantBranchUpdate",
"knowledge_update"
].forEach(eventName => {
if (this.socket) { if (this.socket) {
this.socket.off(eventName, this.handleEvent); this.socket.off(eventName, this.handleEvent);
} }
}); });
if (this.daemonSocket) { if (this.daemonSocket) {
this.daemonSocket.removeEventListener('message', this.handleDaemonMessage); this.daemonSocket.removeEventListener('message', this.handleDaemonMessage);
} }
}, },
methods: { methods: {
async loadBranches() { async loadBranches() {
try { try {
@@ -117,6 +160,7 @@ export default {
console.error('Error loading branches:', error); console.error('Error loading branches:', error);
} }
}, },
async loadProducts() { async loadProducts() {
try { try {
const productsResult = await apiClient.get('/api/falukant/products'); const productsResult = await apiClient.get('/api/falukant/products');
@@ -125,31 +169,34 @@ export default {
console.error('Error loading products:', error); console.error('Error loading products:', error);
} }
}, },
handleEvent(event) {
if (event.type === 'branchUpdated') { onBranchSelected(newBranch) {
this.loadBranches(); this.selectedBranch = newBranch;
},
async createBranch() {
// Nach erfolgreichem Dialog-Event: neu laden
await this.loadBranches();
},
upgradeBranch() {
if (this.selectedBranch) {
alert(
this.$t(
'falukant.branch.actions.upgradeAlert',
{ branchId: this.selectedBranch.id }
)
);
} }
}, },
handleDaemonMessage(event) {
const message = JSON.parse(event.data);
},
selectMainBranch() { selectMainBranch() {
const main = this.branches.find(b => b.isMainBranch) || null; const main = this.branches.find(b => b.isMainBranch) || null;
if (main && main !== this.selectedBranch) { if (main && main !== this.selectedBranch) {
this.selectedBranch = main; this.selectedBranch = main;
} }
}, },
onBranchSelected(newBranch) {
this.selectedBranch = newBranch;
},
createBranch() {
alert(this.$t('falukant.branch.actions.createAlert'));
},
upgradeBranch() {
if (this.selectedBranch) {
alert(this.$t('falukant.branch.actions.upgradeAlert', { branchId: this.selectedBranch.id }));
}
},
calculateProductRevenue(product) { calculateProductRevenue(product) {
if (!product.knowledges || product.knowledges.length === 0) { if (!product.knowledges || product.knowledges.length === 0) {
return { absolute: 0, perMinute: 0 }; return { absolute: 0, perMinute: 0 };
@@ -158,55 +205,61 @@ export default {
const maxPrice = product.sellCost; const maxPrice = product.sellCost;
const minPrice = maxPrice * 0.6; const minPrice = maxPrice * 0.6;
const revenuePerUnit = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100); const revenuePerUnit = minPrice + (maxPrice - minPrice) * (knowledgeFactor / 100);
const perMinute = product.productionTime > 0 ? revenuePerUnit / product.productionTime : 0; const perMinute = product.productionTime > 0
? revenuePerUnit / product.productionTime
: 0;
return { return {
absolute: revenuePerUnit.toFixed(2), absolute: revenuePerUnit.toFixed(2),
perMinute: perMinute.toFixed(2), perMinute: perMinute.toFixed(2),
}; };
}, },
calculateProductProfit(product) { calculateProductProfit(product) {
const { absolute: revenueAbsoluteStr, perMinute: revenuePerMinuteStr } = this.calculateProductRevenue(product); const { absolute: revenueAbsoluteStr, perMinute: revenuePerMinuteStr }
= this.calculateProductRevenue(product);
const revenueAbsolute = parseFloat(revenueAbsoluteStr); const revenueAbsolute = parseFloat(revenueAbsoluteStr);
const costPerUnit = 6 * product.category; const costPerUnit = 6 * product.category;
const profitAbsolute = revenueAbsolute - costPerUnit; const profitAbsolute = revenueAbsolute - costPerUnit;
const costPerMinute = product.productionTime > 0 ? costPerUnit / product.productionTime : 0; const costPerMinute = product.productionTime > 0
? costPerUnit / product.productionTime
: 0;
const profitPerMinute = parseFloat(revenuePerMinuteStr) - costPerMinute; const profitPerMinute = parseFloat(revenuePerMinuteStr) - costPerMinute;
return { return {
absolute: profitAbsolute.toFixed(2), absolute: profitAbsolute.toFixed(2),
perMinute: profitPerMinute.toFixed(2), perMinute: profitPerMinute.toFixed(2),
}; };
}, },
// Gemeinsamer Event-Handler für socket-Events
handleEvent(eventData) { handleEvent(eventData) {
switch (eventData.event || eventData) { switch (eventData.event) {
case 'production_ready': case 'production_ready':
this.$refs.productionSection && this.$refs.productionSection.loadProductions(); this.$refs.productionSection?.loadProductions();
this.$refs.storageSection && this.$refs.storageSection.loadStorageData(); this.$refs.storageSection ?.loadStorageData();
this.$refs.saleSection && this.$refs.saleSection.loadInventory(); this.$refs.saleSection ?.loadInventory();
break; break;
case 'stock_change': case 'stock_change':
this.$refs.storageSection && this.$refs.storageSection.loadStorageData(); this.$refs.storageSection ?.loadStorageData();
this.$refs.saleSection && this.$refs.saleSection.loadInventory(); this.$refs.saleSection ?.loadInventory();
break; break;
case 'price_update': case 'price_update':
this.$refs.revenueSection && this.$refs.revenueSection.refresh && this.$refs.revenueSection.refresh(); this.$refs.revenueSection?.refresh();
break; break;
case 'director_death': case 'director_death':
this.$refs.directorInfo && this.$refs.directorInfo.loadDirector(); this.$refs.directorInfo?.loadDirector();
break; break;
case 'production_started': case 'production_started':
this.$refs.productionSection && this.$refs.productionSection.loadProductions(); this.$refs.productionSection?.loadProductions();
break; break;
case 'selled_items': case 'selled_items':
this.$refs.saleSection && this.$refs.saleSection.loadInventory(); this.$refs.saleSection ?.loadInventory();
this.$refs.storageSection && this.$refs.storageSection.loadStorageData(); this.$refs.storageSection?.loadStorageData();
break; break;
case 'falukantUpdateStatus': case 'falukantUpdateStatus':
case 'falukantBranchUpdate': case 'falukantBranchUpdate':
this.$refs.statusBar && this.$refs.statusBar.updateStatus && this.$refs.statusBar.updateStatus(eventData); this.$refs.statusBar?.fetchStatus();
this.$refs.productionSection && this.$refs.productionSection.loadProductions(); this.$refs.productionSection?.loadProductions();
this.$refs.storageSection && this.$refs.storageSection.loadStorageData(); this.$refs.storageSection ?.loadStorageData();
this.$refs.saleSection && this.$refs.saleSection.loadInventory(); this.$refs.saleSection ?.loadInventory();
break; break;
case 'knowledge_update': case 'knowledge_update':
this.loadProducts(); this.loadProducts();
@@ -216,13 +269,14 @@ export default {
console.log('Unhandled event:', eventData); console.log('Unhandled event:', eventData);
} }
}, },
handleDaemonMessage(event) { handleDaemonMessage(event) {
if (event.data === "ping") return; if (event.data === 'ping') return;
try { try {
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
this.handleEvent(message); this.handleEvent(message);
} catch (error) { } catch (error) {
console.error('Error processing daemon message in BranchView:', error); console.error('Error processing daemon message:', error);
} }
}, },
}, },
@@ -234,3 +288,4 @@ h2 {
padding-top: 20px; padding-top: 20px;
} }
</style> </style>

View File

@@ -47,8 +47,9 @@
</tr> </tr>
</table> </table>
<ul> <ul>
<li v-for="characteristic in relationships[0].character2.characterTrait" <li v-for="trait in relationships[0].character2.traits" :key="trait.id">
:key="characteristic.id">{{ $t(`falukant.character.${characteristic.tr}`) }}</li> {{ $t(`falukant.character.${trait.tr}`) }}
</li>
</ul> </ul>
</div> </div>
<div v-if="relationships[0].relationshipType === 'wooing'"> <div v-if="relationships[0].relationshipType === 'wooing'">
@@ -64,9 +65,11 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="gift in gifts" :key="gift.id"> <tr v-for="gift in gifts" :key="gift.id">
<td><input type="radio" name="gift" :value="gift.id" v-model="selectedGiftId"></td> <td>
<input type="radio" name="gift" :value="gift.id" v-model="selectedGiftId" />
</td>
<td>{{ $t(`falukant.gifts.${gift.name}`) }}</td> <td>{{ $t(`falukant.gifts.${gift.name}`) }}</td>
<td>{{ $t(`falukant.family.spouse.giftAffect.${getEffect(gift)}`) }}</td> <td>{{ getEffect(gift) }}</td>
<td>{{ formatCost(gift.cost) }}</td> <td>{{ formatCost(gift.cost) }}</td>
</tr> </tr>
</tbody> </tbody>
@@ -123,7 +126,8 @@
{{ child.name }} {{ child.name }}
</td> </td>
<td v-else> <td v-else>
<button @click="jumpToChurchForm">{{ $t('falukant.family.children.baptism') }}</button> <button @click="jumpToChurchForm">{{ $t('falukant.family.children.baptism')
}}</button>
</td> </td>
<td>{{ child.age }}</td> <td>{{ child.age }}</td>
<td> <td>
@@ -308,13 +312,39 @@ export default {
}); });
}, },
handleDaemonMessage() { handleDaemonMessage(event) {
if (event.data === 'ping') {
return;
}
const message = JSON.parse(event.data); const message = JSON.parse(event.data);
if (message.event === 'children_update') { if (message.event === 'children_update') {
this.loadFamilyData(); this.loadFamilyData();
} }
},
getEffect(gift) {
// aktueller Partner
const partner = this.relationships[0].character2;
// seine aktuelle Mood-ID
const moodId = partner.mood?.id ?? partner.mood_id;
// 1) Mood-Eintrag finden
const moodEntry = gift.moodsAffects.find(ma => ma.mood_id === moodId);
const moodValue = moodEntry ? moodEntry.suitability : 0;
// 2) Trait-Einträge matchen
let highestTraitValue = 0;
for (const trait of partner.traits) {
const charEntry = gift.charactersAffects.find(ca => ca.trait_id === trait.id);
if (charEntry && charEntry.suitability > highestTraitValue) {
highestTraitValue = charEntry.suitability;
} }
} }
// Durchschnitt, gerundet
return Math.round((moodValue + highestTraitValue) / 2);
},
}
} }
</script> </script>

View File

@@ -13,7 +13,8 @@
</tr> </tr>
<tr> <tr>
<td>{{ $t('falukant.overview.metadata.nobleTitle') }}</td> <td>{{ $t('falukant.overview.metadata.nobleTitle') }}</td>
<td>{{ $t('falukant.titles.' + falukantUser?.character.gender + '.' + falukantUser?.character.nobleTitle.labelTr) }}</td> <td>{{ $t('falukant.titles.' + falukantUser?.character.gender + '.' +
falukantUser?.character.nobleTitle.labelTr) }}</td>
</tr> </tr>
<tr> <tr>
<td>{{ $t('falukant.overview.metadata.money') }}</td> <td>{{ $t('falukant.overview.metadata.money') }}</td>
@@ -170,17 +171,24 @@ export default {
}; };
}, },
getHouseStyle() { getHouseStyle() {
if (!this.falukantUser) return {}; console.log(this.falukantUser);
if (!this.falukantUser || !this.falukantUser.userHouse?.houseType) return {};
const imageUrl = '/images/falukant/houses.png'; const imageUrl = '/images/falukant/houses.png';
const housePosition = this.falukantUser.house ? this.falukantUser.house.type.position : 0; const pos = this.falukantUser.userHouse.houseType.position;
const x = housePosition % 3; const index = pos - 1;
const y = Math.floor(housePosition / 3); const columns = 3;
const spriteSize = 300;
const x = (index % columns) * spriteSize;
const y = Math.floor(index / columns) * spriteSize;
return { return {
backgroundImage: `url(${imageUrl})`, backgroundImage: `url(${imageUrl})`,
backgroundPosition: `-${x * 341}px -${y * 341}px`, backgroundPosition: `-${x}px -${y}px`,
backgroundSize: "341px 341px", backgroundSize: `${columns * spriteSize}px auto`,
width: "114px", width: `300px`,
height: "114px", height: `300px`,
border: '1px solid #ccc',
borderRadius: '4px',
imageRendering: 'crisp-edges',
}; };
}, },
getAgeColor(age) { getAgeColor(age) {
@@ -321,12 +329,10 @@ export default {
border: 1px solid #ccc; border: 1px solid #ccc;
border-radius: 4px; border-radius: 4px;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover;
image-rendering: crisp-edges; image-rendering: crisp-edges;
} }
h2 { h2 {
padding-top: 20px; padding-top: 20px;
} }
</style> </style>

View File

@@ -0,0 +1,405 @@
<template>
<div class="politics-view">
<StatusBar />
<h2>{{ $t('falukant.politics.title') }}</h2>
<SimpleTabs v-model="activeTab" :tabs="tabs" @change="onTabChange" />
<!-- TabInhalt -->
<div class="tab-content">
<!-- Aktuelle Positionen -->
<div v-if="activeTab === 'current'" class="tab-pane">
<div v-if="loading.current" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="politics-table">
<thead>
<tr>
<th>{{ $t('falukant.politics.current.office') }}</th>
<th>{{ $t('falukant.politics.current.region') }}</th>
<th>{{ $t('falukant.politics.current.holder') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="pos in currentPositions" :key="pos.id">
<td>{{ $t(`falukant.politics.offices.${pos.officeType.name}`) }}</td>
<td>{{ pos.region.name }}</td>
<td>
<span v-if="pos.character">
{{ pos.character.definedFirstName.name }}
{{ pos.character.definedLastName.name }}
</span>
<span v-else></span>
</td>
</tr>
<tr v-if="!currentPositions.length">
<td colspan="3">{{ $t('falukant.politics.current.none') }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- OPEN Tab: hier zeigen wir 'openPolitics' -->
<div v-else-if="activeTab === 'openPolitics'" class="tab-pane">
<div v-if="loading.openPolitics" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="politics-table">
<thead>
<tr>
<th>{{ $t('falukant.politics.open.office') }}</th>
<th>{{ $t('falukant.politics.open.region') }}</th>
<th>{{ $t('falukant.politics.open.date') }}</th>
<th>{{ $t('falukant.politics.open.candidacy') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="e in openPolitics" :key="e.id">
<td>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</td>
<td>{{ e.region.name }}</td>
<td>{{ formatDate(e.date) }}</td>
<!-- Checkbox ganz am Ende -->
<td>
<input type="checkbox" :id="`apply-${e.id}`" v-model="selectedApplications"
:value="e.id" />
</td>
</tr>
<tr v-if="!openPolitics.length">
<td colspan="4">{{ $t('falukant.politics.open.none') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="apply-button">
<button :disabled="!selectedApplications.length" @click="submitApplications">
{{ $t('falukant.politics.open.apply') }}
</button>
</div>
</div>
<!-- Wahlen -->
<div v-else-if="activeTab === 'elections'" class="tab-pane">
<div v-if="loading.elections" class="loading">{{ $t('loading') }}</div>
<div v-else class="table-scroll">
<table class="politics-table">
<thead>
<tr>
<th>{{ $t('falukant.politics.elections.office') }}</th>
<th>{{ $t('falukant.politics.elections.region') }}</th>
<th>{{ $t('falukant.politics.elections.date') }}</th>
<th>{{ $t('falukant.politics.elections.posts') }}</th>
<th>{{ $t('falukant.politics.elections.candidates') }}</th>
<th>{{ $t('falukant.politics.elections.action') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="e in elections" :key="e.id">
<td>{{ $t(`falukant.politics.offices.${e.officeType.name}`) }}</td>
<td>{{ e.region.name }}</td>
<td>{{ formatDate(e.date) }}</td>
<td>{{ e.postsToFill }}</td>
<td v-if="!e.voted">
<Multiselect v-model="selectedCandidates[e.id]" :options="e.candidates" multiple
:max="e.postsToFill" :close-on-select="false" :clear-on-select="false"
track-by="id" label="name" :custom-label="candidateLabel" placeholder="">
<template #option="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }} ({{ option.age }})
</template>
<template #selected="{ option }">
{{ $t(`falukant.titles.${option.gender}.${option.title}`) }}
{{ option.name }}
</template>
</Multiselect>
</td>
<td v-else>
<ul class="voted-list">
<li v-for="cid in e.votedFor" :key="cid">
<span v-if="findCandidateById(e, cid)">
{{ formatCandidateTitle(findCandidateById(e, cid)) }}
{{ findCandidateById(e, cid).name }}
</span>
</li>
<li v-if="!e.votedFor || !e.votedFor.length"></li>
</ul>
</td>
<td>
<button v-if="!e.voted"
:disabled="!selectedCandidates[e.id] || !selectedCandidates[e.id].length"
@click="submitVote(e.id)">
{{ $t('falukant.politics.elections.vote') }}
</button>
</td>
</tr>
<tr v-if="!elections.length">
<td colspan="6">{{ $t('falukant.politics.elections.none') }}</td>
</tr>
</tbody>
</table>
</div>
<div class="all-vote-button" v-if="hasAnyUnvoted">
<button :disabled="!hasAnySelection" @click="submitAllVotes">
{{ $t('falukant.politics.elections.voteAll') }}
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import StatusBar from '@/components/falukant/StatusBar.vue';
import SimpleTabs from '@/components/SimpleTabs.vue';
import Multiselect from 'vue-multiselect';
import apiClient from '@/utils/axios.js';
export default {
name: 'PoliticsView',
components: { StatusBar, SimpleTabs, Multiselect },
data() {
return {
activeTab: 'current',
tabs: [
{ value: 'current', label: 'falukant.politics.tabs.current' },
{ value: 'openPolitics', label: 'falukant.politics.tabs.upcoming' },
{ value: 'elections', label: 'falukant.politics.tabs.elections' }
],
currentPositions: [],
openPolitics: [],
elections: [],
selectedCandidates: {},
selectedApplications: [],
loading: {
current: false,
openPolitics: false,
elections: false
}
};
},
computed: {
hasAnySelection() {
return Object.values(this.selectedCandidates)
.some(arr => Array.isArray(arr) && arr.length > 0);
},
hasAnyUnvoted() {
return this.elections.some(e => !e.voted);
}
},
mounted() {
this.loadCurrentPositions();
},
methods: {
onTabChange(tab) {
if (tab === 'current' && !this.currentPositions.length) {
this.loadCurrentPositions();
}
if (tab === 'openPolitics' && !this.openPolitics.length) {
this.loadOpenPolitics();
}
if (tab === 'elections' && !this.elections.length) {
this.loadElections();
}
},
async loadCurrentPositions() {
this.loading.current = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/overview');
this.currentPositions = data;
} catch (err) {
console.error('Error loading current positions', err);
} finally {
this.loading.current = false;
}
},
async loadOpenPolitics() {
this.loading.openPolitics = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/open');
this.openPolitics = data;
this.selectedApplications = [];
} catch (err) {
console.error('Error loading open politics', err);
} finally {
this.loading.openPolitics = false;
}
},
async loadElections() {
this.loading.elections = true;
try {
const { data } = await apiClient.get('/api/falukant/politics/elections');
this.elections = data;
data.forEach(e => {
this.selectedCandidates[e.id] = [];
});
} catch (err) {
console.error('Error loading elections', err);
} finally {
this.loading.elections = false;
}
},
candidateLabel(option) {
const title = this.$t(`falukant.titles.${option.gender}.${option.title}`);
return `${title} ${option.name} (${option.age})`;
},
findCandidateById(election, candidateId) {
return election.candidates.find(c => c.id === candidateId) || {};
},
formatCandidateTitle(candidate) {
if (!candidate) return '';
return this.$t(`falukant.titles.${candidate.gender}.${candidate.title}`);
},
async submitVote(electionId) {
const singlePayload = [
{
electionId: electionId,
candidateIds: this.selectedCandidates[electionId].map(c => c.id)
}
];
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: singlePayload }
);
await this.loadElections();
} catch (err) {
console.error(`Error submitting vote for election ${electionId}`, err);
}
},
async submitAllVotes() {
const payload = Object.entries(this.selectedCandidates)
.filter(([eid, arr]) => Array.isArray(arr) && arr.length > 0)
.map(([eid, arr]) => ({
electionId: parseInt(eid, 10),
candidateIds: arr.map(c => c.id)
}));
try {
await apiClient.post(
'/api/falukant/politics/elections',
{ votes: payload }
);
await this.loadElections();
} catch (err) {
console.error('Error submitting all votes', err);
}
},
formatDate(ts) {
return new Date(ts).toLocaleDateString(this.$i18n.locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
},
async submitApplications() {
try {
await apiClient.post(
'/api/falukant/politics/open',
{ electionIds: this.selectedApplications }
);
await this.loadOpenPolitics();
} catch (err) {
console.error('Error submitting applications', err);
}
}
}
};
</script>
<style scoped>
.politics-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
h2 {
margin: 0;
padding: 20px 0 0 0;
flex: 0 0 auto;
}
.simple-tabs {
flex: 0 0 auto;
}
.tab-content {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tab-pane {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.table-scroll {
flex: 1;
overflow-y: auto;
border: 1px solid #ddd;
}
.politics-table {
border-collapse: collapse;
width: auto;
/* kein 100% */
}
.politics-table thead th {
position: sticky;
top: 0;
background: #FFF;
z-index: 1;
padding: 8px;
border: 1px solid #ddd;
text-align: left;
}
.politics-table tbody td {
padding: 8px;
border: 1px solid #ddd;
}
.loading {
text-align: center;
font-style: italic;
margin: 20px 0;
}
.voted-list {
list-style: none;
margin: 0;
padding: 0;
}
.all-vote-button {
padding: 10px 0;
text-align: right;
}
.all-vote-button button {
padding: 6px 12px;
cursor: pointer;
margin: 2em;
}
</style>

View File

@@ -2,89 +2,84 @@
<h2 class="link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2> <h2 class="link" @click="openForum()">{{ $t('socialnetwork.forum.title') }} {{ forumName }}</h2>
<h3 v-if="forumTopic">{{ forumTopic }}</h3> <h3 v-if="forumTopic">{{ forumTopic }}</h3>
<ul class="messages"> <ul class="messages">
<li v-for="message in messages"> <li v-for="message in messages" :key="message.id">
<div v-html="message.text"></div> <div v-html="message.text"></div>
<div class="footer"> <div class="footer">
<span class="link" @click="openProfile(message.lastMessageUser.hashedId)">{{ <span class="link" @click="openProfile(message.lastMessageUser.hashedId)">
message.lastMessageUser.username }}</span> {{ message.lastMessageUser.username }}
</span>
<span>{{ new Date(message.createdAt).toLocaleString() }}</span> <span>{{ new Date(message.createdAt).toLocaleString() }}</span>
</div> </div>
</li> </li>
</ul> </ul>
<editor v-model="newContent" :init="tinymceInitOptions" :api-key="apiKey"
tinymce-script-src="/tinymce/tinymce.min.js"></editor> <div class="editor-container">
<EditorContent :editor="editor" class="editor" />
</div>
<button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button> <button @click="saveNewMessage">{{ $t('socialnetwork.forum.createNewMesssage') }}</button>
</template> </template>
<script> <script>
import apiClient from '../../utils/axios'; import { Editor, EditorContent } from '@tiptap/vue-3'
import TinyMCEEditor from '@tinymce/tinymce-vue'; import StarterKit from '@tiptap/starter-kit'
import apiClient from '../../utils/axios'
export default { export default {
name: 'ForumTopicView', name: 'ForumTopicView',
components: { components: {
editor: TinyMCEEditor, EditorContent,
}, },
data() { data() {
return { return {
forumTopicId: String, forumTopicId: '',
forumTopic: null, forumTopic: null,
forumName: null, forumName: null,
forumId: 0, forumId: 0,
newContent: '', messages: [],
apiKey: import.meta.env.VITE_TINYMCE_API_KEY ?? '', editor: null,
tinymceInitOptions: {
script_url: '/tinymce/tinymce.min.js',
height: 300,
menubar: true,
plugins: [
'lists', 'link',
'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'table'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor backcolor fontfamily fontsize| \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | link visualblocks code',
contextmenu: 'link image table',
menubar: 'edit format table',
promotion: false,
},
} }
}, },
async mounted() { mounted() {
this.forumTopicId = this.$route.params.id; this.forumTopicId = this.$route.params.id;
this.loadForumTopic(); this.loadForumTopic();
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
},
beforeUnmount() {
if (this.editor) {
this.editor.destroy();
}
}, },
methods: { methods: {
async loadForumTopic() { async loadForumTopic() {
try { try {
console.log(this.forumTopicId); const response = await apiClient.get(`/api/forum/topic/${this.forumTopicId}`);
const url = `/api/forum/topic/${this.forumTopicId}`;
console.log('url', url);
const response = await apiClient.get(url);
this.setContent(response.data); this.setContent(response.data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}, },
setContent(responseData) { setContent(data) {
this.forumTopic = responseData.title; this.forumTopic = data.title;
this.forumName = responseData.forum.name; this.forumName = data.forum.name;
this.forumId = responseData.forum.id; this.forumId = data.forum.id;
this.messages = responseData.messages; this.messages = data.messages;
}, },
async openProfile(id) { async openProfile(id) {
this.$root.$refs.userProfileDialog.userId = id; this.$root.$refs.userProfileDialog.userId = id;
this.$root.$refs.userProfileDialog.open(); this.$root.$refs.userProfileDialog.open();
}, },
async saveNewMessage() { async saveNewMessage() {
const content = this.editor ? this.editor.getHTML() : '';
if (!content.trim()) return;
try { try {
const url = `/api/forum/topic/${this.forumTopicId}/message`; const url = `/api/forum/topic/${this.forumTopicId}/message`;
const response = await apiClient.post(url, { const response = await apiClient.post(url, { content });
content: this.newContent, this.editor.commands.clearContent();
});
this.newContent = '';
this.setContent(response.data); this.setContent(response.data);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -96,7 +91,6 @@ export default {
} }
} }
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.messages { .messages {
list-style-type: none; list-style-type: none;
@@ -124,4 +118,17 @@ export default {
.messages > li > .footer > span:last-child { .messages > li > .footer > span:last-child {
text-align: right; text-align: right;
} }
.editor-container {
margin-top: 1rem;
border: 1px solid #ccc;
padding: 10px;
min-height: 200px;
background-color: white;
}
.editor {
min-height: 150px;
outline: none;
}
</style> </style>

View File

@@ -11,71 +11,27 @@
<input type="text" v-model="newTitle" /> <input type="text" v-model="newTitle" />
</label> </label>
</div> </div>
<editor v-model="newContent" :init="tinymceInitOptions" :api-key="apiKey" <div class="editor-container">
tinymce-script-src="/tinymce/tinymce.min.js"></editor> <EditorContent :editor="editor" class="editor" />
</div>
<button @click="saveNewTopic">{{ $t('socialnetwork.forum.createNewTopic') }}</button> <button @click="saveNewTopic">{{ $t('socialnetwork.forum.createNewTopic') }}</button>
</div> </div>
<div v-else-if="titles.length > 0"> <div v-else-if="titles.length > 0">
<div class="pagination"> <!-- PAGINATION + TABLE bleibt unverändert -->
<button @click="goToPage(1)" v-if="page != 1">&laquo; {{ $t('socialnetwork.forum.pagination.first') <!-- ... -->
}}</button>
<button @click="goToPage(page - 1)" v-if="page != 1">&lsaquo; {{
$t('socialnetwork.forum.pagination.previous') }}</button>
<span>{{ $t('socialnetwork.forum.pagination.page').replace("<<page>>", page).replace("<<of>>", totalPages)
}}</span>
<button @click="goToPage(page + 1)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.next')
}}
&rsaquo;</button>
<button @click="goToPage(totalPages)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.last')
}}
&raquo;</button>
</div>
<table>
<thead>
<tr>
<th>{{ $t('socialnetwork.forum.topic') }}</th>
<th>{{ $t('socialnetwork.forum.createdBy') }}</th>
<th>{{ $t('socialnetwork.forum.createdAt') }}</th>
<th>{{ $t('socialnetwork.forum.reactions') }}</th>
<th>{{ $t('socialnetwork.forum.lastReaction') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="title in titles">
<td><span class="link" @click="openTopic(title.id)">{{ title.title }}</span></td>
<td><span class="link" @click="openProfile(title.createdByHash)">{{ title.createdBy }}</span></td>
<td>{{ new Date(title.createdAt).toLocaleString() }}</td>
<td>{{ title.numberOfItems }}</td>
<td>{{ new Date(title.lastMessageDate).toLocaleString() }}</td>
</tr>
</tbody>
</table>
<div class="pagination">
<button @click="goToPage(1)" v-if="page != 1">&laquo; {{ $t('socialnetwork.forum.pagination.first')
}}</button>
<button @click="goToPage(page - 1)" v-if="page != 1">&lsaquo; {{
$t('socialnetwork.forum.pagination.previous') }}</button>
<span>{{ $t('socialnetwork.forum.pagination.page').replace("<<page>>", page).replace("<<of>>", totalPages)
}}</span>
<button @click="goToPage(page + 1)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.next')
}}
&rsaquo;</button>
<button @click="goToPage(totalPages)" v-if="page != totalPages">{{ $t('socialnetwork.forum.pagination.last')
}}
&raquo;</button>
</div>
</div> </div>
<div v-else>{{ $t('socialnetwork.forum.noTitles') }}</div> <div v-else>{{ $t('socialnetwork.forum.noTitles') }}</div>
</template> </template>
<script> <script>
import apiClient from '../../utils/axios'; import { Editor, EditorContent } from '@tiptap/vue-3'
import TinyMCEEditor from '@tinymce/tinymce-vue'; import StarterKit from '@tiptap/starter-kit'
import apiClient from '../../utils/axios'
export default { export default {
name: 'ForumView', name: 'ForumView',
components: { components: {
editor: TinyMCEEditor, EditorContent,
}, },
computed: { computed: {
totalPages() { totalPages() {
@@ -91,30 +47,20 @@ export default {
titles: [], titles: [],
inCreation: false, inCreation: false,
newTitle: '', newTitle: '',
newContent: '', editor: null,
apiKey: import.meta.env.VITE_TINYMCE_API_KEY ?? '',
tinymceInitOptions: {
script_url: '/tinymce/tinymce.min.js',
height: 300,
menubar: true,
plugins: [
'lists', 'link',
'searchreplace', 'visualblocks', 'code',
'insertdatetime', 'table'
],
toolbar:
'undo redo cut copy paste | bold italic forecolor backcolor fontfamily fontsize| \
alignleft aligncenter alignright alignjustify | \
bullist numlist outdent indent | removeformat | link visualblocks code',
contextmenu: 'link image table',
menubar: 'edit format table',
promotion: false,
},
} }
}, },
async mounted() { async mounted() {
this.forumId = this.$route.params.id; this.forumId = this.$route.params.id;
await this.loadForum(); await this.loadForum();
this.editor = new Editor({
extensions: [StarterKit],
content: '',
});
},
beforeUnmount() {
if (this.editor) this.editor.destroy();
}, },
methods: { methods: {
async loadForum() { async loadForum() {
@@ -123,12 +69,16 @@ export default {
}, },
createNewTopic() { createNewTopic() {
this.inCreation = !this.inCreation; this.inCreation = !this.inCreation;
if (this.inCreation && this.editor) {
this.editor.commands.setContent('');
}
}, },
async saveNewTopic() { async saveNewTopic() {
const content = this.editor ? this.editor.getHTML() : '';
const response = await apiClient.post('/api/forum/topic', { const response = await apiClient.post('/api/forum/topic', {
forumId: this.forumId, forumId: this.forumId,
title: this.newTitle, title: this.newTitle,
content: this.newEntryContent content,
}); });
this.setData(response.data); this.setData(response.data);
this.inCreation = false; this.inCreation = false;
@@ -171,6 +121,19 @@ export default {
flex: 1; flex: 1;
} }
.editor-container {
margin: 1em 0;
border: 1px solid #ccc;
padding: 10px;
min-height: 200px;
background-color: white;
}
.editor {
min-height: 150px;
outline: none;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: center; justify-content: center;