diff --git a/backend/controllers/falukantController.js b/backend/controllers/falukantController.js
index c9ff440..81ab7da 100644
--- a/backend/controllers/falukantController.js
+++ b/backend/controllers/falukantController.js
@@ -148,6 +148,20 @@ class FalukantController {
this.getPoliticsOverview = this._wrapWithUser((userId) => this.service.getPoliticsOverview(userId));
this.getOpenPolitics = this._wrapWithUser((userId) => this.service.getOpenPolitics(userId));
+
+ // Church career endpoints
+ this.getChurchOverview = this._wrapWithUser((userId) => this.service.getChurchOverview(userId));
+ this.getAvailableChurchPositions = this._wrapWithUser((userId) => this.service.getAvailableChurchPositions(userId));
+ this.applyForChurchPosition = this._wrapWithUser((userId, req) => {
+ const { officeTypeId, regionId } = req.body;
+ return this.service.applyForChurchPosition(userId, officeTypeId, regionId);
+ }, { successStatus: 201 });
+ this.getSupervisedApplications = this._wrapWithUser((userId) => this.service.getSupervisedApplications(userId));
+ this.decideOnChurchApplication = this._wrapWithUser((userId, req) => {
+ const { applicationId, decision } = req.body;
+ return this.service.decideOnChurchApplication(userId, applicationId, decision);
+ });
+ this.hasChurchCareer = this._wrapWithUser((userId) => this.service.hasChurchCareer(userId));
this.getElections = this._wrapWithUser((userId) => this.service.getElections(userId));
this.vote = this._wrapWithUser((userId, req) => this.service.vote(userId, req.body.votes));
this.applyForElections = this._wrapWithUser((userId, req) => this.service.applyForElections(userId, req.body.electionIds));
diff --git a/backend/models/associations.js b/backend/models/associations.js
index d6351ad..4a240cf 100644
--- a/backend/models/associations.js
+++ b/backend/models/associations.js
@@ -93,6 +93,10 @@ import PoliticalOfficeRequirement from './falukant/predefine/political_office_pr
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';
+import ChurchOfficeType from './falukant/type/church_office_type.js';
+import ChurchOfficeRequirement from './falukant/predefine/church_office_requirement.js';
+import ChurchOffice from './falukant/data/church_office.js';
+import ChurchApplication from './falukant/data/church_application.js';
import Underground from './falukant/data/underground.js';
import UndergroundType from './falukant/type/underground.js';
import VehicleType from './falukant/type/vehicle.js';
@@ -866,6 +870,86 @@ export default function setupAssociations() {
}
);
+ // — Church Offices —
+
+ // Requirements for church office
+ ChurchOfficeRequirement.belongsTo(ChurchOfficeType, {
+ foreignKey: 'officeTypeId',
+ as: 'officeType'
+ });
+ ChurchOfficeType.hasMany(ChurchOfficeRequirement, {
+ foreignKey: 'officeTypeId',
+ as: 'requirements'
+ });
+
+ // Prerequisite office type
+ ChurchOfficeRequirement.belongsTo(ChurchOfficeType, {
+ foreignKey: 'prerequisiteOfficeTypeId',
+ as: 'prerequisiteOfficeType'
+ });
+
+ // Actual church office holdings
+ ChurchOffice.belongsTo(ChurchOfficeType, {
+ foreignKey: 'officeTypeId',
+ as: 'type'
+ });
+ ChurchOfficeType.hasMany(ChurchOffice, {
+ foreignKey: 'officeTypeId',
+ as: 'offices'
+ });
+
+ ChurchOffice.belongsTo(FalukantCharacter, {
+ foreignKey: 'characterId',
+ as: 'holder'
+ });
+ FalukantCharacter.hasOne(ChurchOffice, {
+ foreignKey: 'characterId',
+ as: 'heldChurchOffice'
+ });
+
+ // Supervisor relationship
+ ChurchOffice.belongsTo(FalukantCharacter, {
+ foreignKey: 'supervisorId',
+ as: 'supervisor'
+ });
+
+ // Applications for church office
+ ChurchApplication.belongsTo(ChurchOfficeType, {
+ foreignKey: 'officeTypeId',
+ as: 'officeType'
+ });
+ ChurchOfficeType.hasMany(ChurchApplication, {
+ foreignKey: 'officeTypeId',
+ as: 'applications'
+ });
+
+ ChurchApplication.belongsTo(FalukantCharacter, {
+ foreignKey: 'characterId',
+ as: 'applicant'
+ });
+ FalukantCharacter.hasMany(ChurchApplication, {
+ foreignKey: 'characterId',
+ as: 'churchApplications'
+ });
+
+ ChurchApplication.belongsTo(FalukantCharacter, {
+ foreignKey: 'supervisorId',
+ as: 'supervisor'
+ });
+ FalukantCharacter.hasMany(ChurchApplication, {
+ foreignKey: 'supervisorId',
+ as: 'supervisedApplications'
+ });
+
+ ChurchApplication.belongsTo(RegionData, {
+ foreignKey: 'regionId',
+ as: 'region'
+ });
+ RegionData.hasMany(ChurchApplication, {
+ foreignKey: 'regionId',
+ as: 'churchApplications'
+ });
+
Underground.belongsTo(UndergroundType, {
foreignKey: 'undergroundTypeId',
as: 'undergroundType'
diff --git a/backend/models/falukant/data/church_application.js b/backend/models/falukant/data/church_application.js
new file mode 100644
index 0000000..2515c09
--- /dev/null
+++ b/backend/models/falukant/data/church_application.js
@@ -0,0 +1,47 @@
+import { Model, DataTypes } from 'sequelize';
+import { sequelize } from '../../../utils/sequelize.js';
+
+class ChurchApplication extends Model {}
+
+ChurchApplication.init({
+ id: {
+ type: DataTypes.INTEGER,
+ primaryKey: true,
+ autoIncrement: true
+ },
+ officeTypeId: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ characterId: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ regionId: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ supervisorId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ comment: 'ID des Vorgesetzten, der über die Bewerbung entscheidet'
+ },
+ status: {
+ type: DataTypes.ENUM('pending', 'approved', 'rejected'),
+ allowNull: false,
+ defaultValue: 'pending'
+ },
+ decisionDate: {
+ type: DataTypes.DATE,
+ allowNull: true
+ }
+}, {
+ sequelize,
+ modelName: 'ChurchApplication',
+ tableName: 'church_application',
+ schema: 'falukant_data',
+ timestamps: true,
+ underscored: true
+});
+
+export default ChurchApplication;
diff --git a/backend/models/falukant/data/church_office.js b/backend/models/falukant/data/church_office.js
new file mode 100644
index 0000000..a92dfda
--- /dev/null
+++ b/backend/models/falukant/data/church_office.js
@@ -0,0 +1,38 @@
+import { Model, DataTypes } from 'sequelize';
+import { sequelize } from '../../../utils/sequelize.js';
+
+class ChurchOffice extends Model {}
+
+ChurchOffice.init({
+ id: {
+ type: DataTypes.INTEGER,
+ primaryKey: true,
+ autoIncrement: true
+ },
+ officeTypeId: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ characterId: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ regionId: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ supervisorId: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ comment: 'ID des Vorgesetzten (höhere Position in der Hierarchie)'
+ }
+}, {
+ sequelize,
+ modelName: 'ChurchOffice',
+ tableName: 'church_office',
+ schema: 'falukant_data',
+ timestamps: true,
+ underscored: true
+});
+
+export default ChurchOffice;
diff --git a/backend/models/falukant/predefine/church_office_requirement.js b/backend/models/falukant/predefine/church_office_requirement.js
new file mode 100644
index 0000000..da323da
--- /dev/null
+++ b/backend/models/falukant/predefine/church_office_requirement.js
@@ -0,0 +1,35 @@
+import { Model, DataTypes } from 'sequelize';
+import { sequelize } from '../../../utils/sequelize.js';
+
+class ChurchOfficeRequirement extends Model {}
+
+ChurchOfficeRequirement.init({
+ id: {
+ type: DataTypes.INTEGER,
+ primaryKey: true,
+ autoIncrement: true
+ },
+ officeTypeId: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ prerequisiteOfficeTypeId: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ comment: 'Erforderliche niedrigere Position in der Hierarchie'
+ },
+ minTitleLevel: {
+ type: DataTypes.INTEGER,
+ allowNull: true,
+ comment: 'Mindest-Titel-Level (optional)'
+ }
+}, {
+ sequelize,
+ modelName: 'ChurchOfficeRequirement',
+ tableName: 'church_office_requirement',
+ schema: 'falukant_predefine',
+ timestamps: false,
+ underscored: true
+});
+
+export default ChurchOfficeRequirement;
diff --git a/backend/models/falukant/type/church_office_type.js b/backend/models/falukant/type/church_office_type.js
new file mode 100644
index 0000000..d5bc710
--- /dev/null
+++ b/backend/models/falukant/type/church_office_type.js
@@ -0,0 +1,38 @@
+import { Model, DataTypes } from 'sequelize';
+import { sequelize } from '../../../utils/sequelize.js';
+
+class ChurchOfficeType extends Model {}
+
+ChurchOfficeType.init({
+ id: {
+ type: DataTypes.INTEGER,
+ primaryKey: true,
+ autoIncrement: true
+ },
+ name: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ seatsPerRegion: {
+ type: DataTypes.INTEGER,
+ allowNull: false
+ },
+ regionType: {
+ type: DataTypes.STRING,
+ allowNull: false
+ },
+ hierarchyLevel: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ comment: 'Höhere Zahl = höhere Position in der Hierarchie'
+ }
+}, {
+ sequelize,
+ modelName: 'ChurchOfficeType',
+ tableName: 'church_office_type',
+ schema: 'falukant_type',
+ timestamps: false,
+ underscored: true
+});
+
+export default ChurchOfficeType;
diff --git a/backend/models/index.js b/backend/models/index.js
index 65b5be4..5fed70c 100644
--- a/backend/models/index.js
+++ b/backend/models/index.js
@@ -113,6 +113,12 @@ import Vote from './falukant/data/vote.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';
+
+// — Kirchliche Ämter (Church) —
+import ChurchOfficeType from './falukant/type/church_office_type.js';
+import ChurchOfficeRequirement from './falukant/predefine/church_office_requirement.js';
+import ChurchOffice from './falukant/data/church_office.js';
+import ChurchApplication from './falukant/data/church_application.js';
import UndergroundType from './falukant/type/underground.js';
import Underground from './falukant/data/underground.js';
import VehicleType from './falukant/type/vehicle.js';
@@ -242,6 +248,10 @@ const models = {
ElectionResult,
PoliticalOfficeHistory,
ElectionHistory,
+ ChurchOfficeType,
+ ChurchOfficeRequirement,
+ ChurchOffice,
+ ChurchApplication,
UndergroundType,
Underground,
WeatherType,
diff --git a/backend/routers/falukantRouter.js b/backend/routers/falukantRouter.js
index d484674..e3b02bf 100644
--- a/backend/routers/falukantRouter.js
+++ b/backend/routers/falukantRouter.js
@@ -59,6 +59,12 @@ router.get('/reputation/actions', falukantController.getReputationActions);
router.post('/reputation/actions', falukantController.executeReputationAction);
router.get('/family/notbaptised', falukantController.getNotBaptisedChildren);
router.post('/church/baptise', falukantController.baptise);
+router.get('/church/overview', falukantController.getChurchOverview);
+router.get('/church/positions/available', falukantController.getAvailableChurchPositions);
+router.post('/church/positions/apply', falukantController.applyForChurchPosition);
+router.get('/church/applications/supervised', falukantController.getSupervisedApplications);
+router.post('/church/applications/decide', falukantController.decideOnChurchApplication);
+router.get('/church/career/check', falukantController.hasChurchCareer);
router.get('/education', falukantController.getEducation);
router.post('/education', falukantController.sendToSchool);
router.get('/bank/overview', falukantController.getBankOverview);
diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js
index 08d0466..af78960 100644
--- a/backend/services/falukantService.js
+++ b/backend/services/falukantService.js
@@ -57,6 +57,10 @@ import PoliticalOfficeHistory from '../models/falukant/log/political_office_hist
import UndergroundType from '../models/falukant/type/underground.js';
import Notification from '../models/falukant/log/notification.js';
import PoliticalOffice from '../models/falukant/data/political_office.js';
+import ChurchOfficeType from '../models/falukant/type/church_office_type.js';
+import ChurchOfficeRequirement from '../models/falukant/predefine/church_office_requirement.js';
+import ChurchOffice from '../models/falukant/data/church_office.js';
+import ChurchApplication from '../models/falukant/data/church_application.js';
import Underground from '../models/falukant/data/underground.js';
import VehicleType from '../models/falukant/type/vehicle.js';
import Vehicle from '../models/falukant/data/vehicle.js';
@@ -1017,6 +1021,11 @@ class FalukantService extends BaseService {
async createTransport(hashedUserId, { branchId, vehicleTypeId, vehicleIds, productId, quantity, targetBranchId }) {
const user = await getFalukantUserOrFail(hashedUserId);
+
+ // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können transportieren)
+ if (await this.hasChurchCareer(hashedUserId)) {
+ throw new Error('churchCareerNoDirectTransactions');
+ }
const sourceBranch = await Branch.findOne({
where: { id: branchId, falukantUserId: user.id },
@@ -1604,6 +1613,12 @@ class FalukantService extends BaseService {
async createProduction(hashedUserId, branchId, productId, quantity) {
const u = await getFalukantUserOrFail(hashedUserId);
+
+ // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können produzieren)
+ if (await this.hasChurchCareer(hashedUserId)) {
+ throw new Error('churchCareerNoDirectTransactions');
+ }
+
const b = await getBranchOrFail(u.id, branchId);
const p = await ProductType.findOne({ where: { id: productId } });
const runningProductions = await Production.findAll({ where: { branchId: b.id } });
@@ -1753,6 +1768,16 @@ class FalukantService extends BaseService {
// Konsistenz wie sellAll: nur aus Stocks dieses Branches verkaufen und alles atomar ausführen
return await sequelize.transaction(async (t) => {
const user = await getFalukantUserOrFail(hashedUserId, { transaction: t });
+
+ // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können verkaufen)
+ const character = await FalukantCharacter.findOne({ where: { userId: user.id }, transaction: t });
+ if (character) {
+ const churchOffice = await ChurchOffice.findOne({ where: { characterId: character.id }, transaction: t });
+ if (churchOffice) {
+ throw new Error('churchCareerNoDirectTransactions');
+ }
+ }
+
const branch = await getBranchOrFail(user.id, branchId);
const character = await FalukantCharacter.findOne({ where: { userId: user.id }, transaction: t });
@@ -1854,6 +1879,16 @@ class FalukantService extends BaseService {
// Sonst kann es (wie beobachtet) zu "teilweise verkauft/gelöscht" kommen.
return await sequelize.transaction(async (t) => {
const falukantUser = await getFalukantUserOrFail(hashedUserId, { transaction: t });
+
+ // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können verkaufen)
+ const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, transaction: t });
+ if (character) {
+ const churchOffice = await ChurchOffice.findOne({ where: { characterId: character.id }, transaction: t });
+ if (churchOffice) {
+ throw new Error('churchCareerNoDirectTransactions');
+ }
+ }
+
const branch = await Branch.findOne({
where: { id: branchId, falukantUserId: falukantUser.id },
include: [{ model: FalukantStock, as: 'stocks' }],
@@ -2191,6 +2226,12 @@ class FalukantService extends BaseService {
async buyStorage(hashedUserId, branchId, amount, stockTypeId) {
const user = await getFalukantUserOrFail(hashedUserId);
+
+ // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können Lager kaufen)
+ if (await this.hasChurchCareer(hashedUserId)) {
+ throw new Error('churchCareerNoDirectTransactions');
+ }
+
const branch = await getBranchOrFail(user.id, branchId);
const buyableStocks = await BuyableStock.findAll({
where: { regionId: branch.regionId, stockTypeId },
@@ -2260,6 +2301,12 @@ class FalukantService extends BaseService {
async sellStorage(hashedUserId, branchId, amount, stockTypeId) {
const user = await getFalukantUserOrFail(hashedUserId);
+
+ // Prüfe, ob User eine kirchliche Karriere hat (dann nur Direktoren können Lager verkaufen)
+ if (await this.hasChurchCareer(hashedUserId)) {
+ throw new Error('churchCareerNoDirectTransactions');
+ }
+
const branch = await getBranchOrFail(user.id, branchId);
const stock = await FalukantStock.findOne({
where: { branchId: branch.id, stockTypeId },
@@ -6239,4 +6286,558 @@ async function enrichNotificationsWithCharacterNames(notifications) {
n.character_name = resolved;
}
}
+
+ // ==================== Church Career Methods ====================
+
+ async getChurchOverview(hashedUserId) {
+ // Liefert alle aktuell besetzten kirchlichen Ämter im eigenen Gebiet
+ const user = await getFalukantUserOrFail(hashedUserId);
+ const character = await FalukantCharacter.findOne({
+ where: { userId: user.id },
+ attributes: ['id', 'regionId']
+ });
+ if (!character) {
+ return [];
+ }
+
+ const relevantRegionIds = await this.getRegionAndParentIds(character.regionId);
+
+ const offices = await ChurchOffice.findAll({
+ where: {
+ regionId: {
+ [Op.in]: relevantRegionIds
+ }
+ },
+ include: [
+ {
+ model: ChurchOfficeType,
+ as: 'type',
+ attributes: ['name', 'hierarchyLevel']
+ },
+ {
+ model: RegionData,
+ as: 'region',
+ attributes: ['name']
+ },
+ {
+ model: FalukantCharacter,
+ as: 'holder',
+ attributes: ['id', 'gender'],
+ include: [
+ {
+ model: FalukantPredefineFirstname,
+ as: 'definedFirstName',
+ attributes: ['name']
+ },
+ {
+ model: FalukantPredefineLastname,
+ as: 'definedLastName',
+ attributes: ['name']
+ },
+ {
+ model: TitleOfNobility,
+ as: 'nobleTitle',
+ attributes: ['labelTr']
+ }
+ ]
+ },
+ {
+ model: FalukantCharacter,
+ as: 'supervisor',
+ attributes: ['id'],
+ include: [
+ {
+ model: FalukantPredefineFirstname,
+ as: 'definedFirstName',
+ attributes: ['name']
+ },
+ {
+ model: FalukantPredefineLastname,
+ as: 'definedLastName',
+ attributes: ['name']
+ }
+ ],
+ required: false
+ }
+ ],
+ order: [
+ [{ model: ChurchOfficeType, as: 'type' }, 'hierarchyLevel', 'ASC'],
+ [{ model: RegionData, as: 'region' }, 'name', 'ASC']
+ ]
+ });
+
+ return offices.map(office => {
+ const o = office.get({ plain: true });
+ return {
+ id: o.id,
+ officeType: {
+ name: o.type.name,
+ hierarchyLevel: o.type.hierarchyLevel
+ },
+ region: {
+ name: o.region.name
+ },
+ character: o.holder ? {
+ id: o.holder.id,
+ name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(),
+ gender: o.holder.gender,
+ title: o.holder.nobleTitle?.labelTr
+ } : null,
+ supervisor: o.supervisor ? {
+ id: o.supervisor.id,
+ name: `${o.supervisor.definedFirstName?.name || ''} ${o.supervisor.definedLastName?.name || ''}`.trim()
+ } : null
+ };
+ });
+ }
+
+ async getAvailableChurchPositions(hashedUserId) {
+ // Liefert verfügbare kirchliche Positionen, für die sich der User bewerben kann
+ const user = await getFalukantUserOrFail(hashedUserId);
+ const character = await FalukantCharacter.findOne({
+ where: { userId: user.id },
+ attributes: ['id', 'regionId'],
+ include: [
+ {
+ model: TitleOfNobility,
+ as: 'nobleTitle',
+ attributes: ['labelTr', 'level']
+ }
+ ]
+ });
+ if (!character) {
+ return [];
+ }
+
+ // Prüfe, ob User bereits ein kirchliches Amt hat
+ const existingOffice = await ChurchOffice.findOne({
+ where: { characterId: character.id }
+ });
+ if (existingOffice) {
+ // User hat bereits ein Amt, kann sich nur auf höhere Positionen bewerben
+ const currentOffice = await ChurchOffice.findOne({
+ where: { characterId: character.id },
+ include: [{
+ model: ChurchOfficeType,
+ as: 'type',
+ attributes: ['hierarchyLevel']
+ }]
+ });
+ const currentLevel = currentOffice?.type?.hierarchyLevel || 0;
+
+ // Finde höhere Positionen
+ const availableOffices = await ChurchOfficeType.findAll({
+ where: {
+ hierarchyLevel: {
+ [Op.gt]: currentLevel
+ }
+ },
+ include: [
+ {
+ model: ChurchOfficeRequirement,
+ as: 'requirements'
+ }
+ ]
+ });
+
+ return this.filterAvailablePositions(availableOffices, character, existingOffice);
+ } else {
+ // User hat noch kein Amt, kann sich auf Einstiegspositionen bewerben
+ const entryLevelOffices = await ChurchOfficeType.findAll({
+ where: {
+ hierarchyLevel: 1 // Dorfgeistlicher ist Einstieg
+ },
+ include: [
+ {
+ model: ChurchOfficeRequirement,
+ as: 'requirements'
+ }
+ ]
+ });
+
+ return this.filterAvailablePositions(entryLevelOffices, character, null);
+ }
+ }
+
+ async filterAvailablePositions(officeTypes, character, existingOffice) {
+ const available = [];
+ const relevantRegionIds = await this.getRegionAndParentIds(character.regionId);
+
+ for (const officeType of officeTypes) {
+ // Prüfe Voraussetzungen
+ const requirements = officeType.requirements || [];
+ let canApply = true;
+
+ // Prüfe, ob Voraussetzung erfüllt ist (niedrigere Position)
+ if (requirements.length > 0 && requirements[0].prerequisiteOfficeTypeId) {
+ if (!existingOffice) {
+ canApply = false; // Benötigt niedrigere Position, aber User hat keine
+ } else {
+ const currentOfficeType = await ChurchOfficeType.findByPk(existingOffice.officeTypeId);
+ if (currentOfficeType?.hierarchyLevel >= officeType.hierarchyLevel) {
+ canApply = false; // Aktuelle Position ist nicht niedrig genug
+ }
+ }
+ }
+
+ if (!canApply) continue;
+
+ // Finde verfügbare Positionen in relevanten Regionen
+ const filledPositions = await ChurchOffice.count({
+ where: {
+ officeTypeId: officeType.id,
+ regionId: {
+ [Op.in]: relevantRegionIds
+ }
+ }
+ });
+
+ // Prüfe, ob noch Plätze frei sind
+ if (filledPositions < officeType.seatsPerRegion) {
+ // Finde Vorgesetzten für diese Position
+ const supervisor = await this.findSupervisorForPosition(officeType, character.regionId);
+
+ // Finde passende Region für diese Position
+ const region = await RegionData.findOne({
+ where: {
+ id: {
+ [Op.in]: relevantRegionIds
+ }
+ },
+ include: [{
+ model: RegionType,
+ as: 'regionType',
+ attributes: ['labelTr']
+ }],
+ order: [['id', 'ASC']] // Nimm die erste passende Region
+ });
+
+ available.push({
+ id: officeType.id,
+ officeType: {
+ name: officeType.name,
+ hierarchyLevel: officeType.hierarchyLevel,
+ seatsPerRegion: officeType.seatsPerRegion,
+ regionType: officeType.regionType
+ },
+ region: region ? {
+ id: region.id,
+ name: region.name
+ } : null,
+ regionId: region?.id || character.regionId,
+ availableSeats: officeType.seatsPerRegion - filledPositions,
+ supervisor: supervisor ? {
+ id: supervisor.id,
+ name: `${supervisor.definedFirstName?.name || ''} ${supervisor.definedLastName?.name || ''}`.trim()
+ } : null
+ });
+ }
+ }
+
+ return available;
+ }
+
+ async findSupervisorForPosition(officeType, regionId) {
+ // Finde den Vorgesetzten (höhere Position in der Hierarchie)
+ const supervisorOfficeType = await ChurchOfficeType.findOne({
+ where: {
+ hierarchyLevel: {
+ [Op.gt]: officeType.hierarchyLevel
+ }
+ },
+ order: [['hierarchyLevel', 'ASC']] // Nimm die nächsthöhere Position
+ });
+
+ if (!supervisorOfficeType) {
+ return null; // Kein Vorgesetzter (z.B. Papst)
+ }
+
+ // Finde den Vorgesetzten in der Region oder übergeordneten Regionen
+ const relevantRegionIds = await this.getRegionAndParentIds(regionId);
+ const supervisorOffice = await ChurchOffice.findOne({
+ where: {
+ officeTypeId: supervisorOfficeType.id,
+ regionId: {
+ [Op.in]: relevantRegionIds
+ }
+ },
+ include: [
+ {
+ model: FalukantCharacter,
+ as: 'holder',
+ attributes: ['id'],
+ include: [
+ {
+ model: FalukantPredefineFirstname,
+ as: 'definedFirstName',
+ attributes: ['name']
+ },
+ {
+ model: FalukantPredefineLastname,
+ as: 'definedLastName',
+ attributes: ['name']
+ }
+ ]
+ }
+ ]
+ });
+
+ return supervisorOffice?.holder || null;
+ }
+
+ async applyForChurchPosition(hashedUserId, officeTypeId, regionId) {
+ // Bewerbung für eine kirchliche Position
+ const user = await getFalukantUserOrFail(hashedUserId);
+ const character = await FalukantCharacter.findOne({
+ where: { userId: user.id },
+ attributes: ['id', 'regionId']
+ });
+ if (!character) {
+ throw new Error('Character not found');
+ }
+
+ // Prüfe, ob bereits eine Bewerbung für diese Position existiert
+ const existingApplication = await ChurchApplication.findOne({
+ where: {
+ characterId: character.id,
+ officeTypeId: officeTypeId,
+ status: 'pending'
+ }
+ });
+ if (existingApplication) {
+ throw new Error('Application already exists');
+ }
+
+ // Finde Vorgesetzten
+ const officeType = await ChurchOfficeType.findByPk(officeTypeId);
+ if (!officeType) {
+ throw new Error('Office type not found');
+ }
+
+ const supervisor = await this.findSupervisorForPosition(officeType, regionId);
+ if (!supervisor) {
+ throw new Error('Supervisor not found');
+ }
+
+ // Erstelle Bewerbung
+ const application = await ChurchApplication.create({
+ officeTypeId: officeTypeId,
+ characterId: character.id,
+ regionId: regionId,
+ supervisorId: supervisor.id,
+ status: 'pending'
+ });
+
+ // Benachrichtige Vorgesetzten
+ const supervisorUser = await FalukantUser.findOne({
+ where: { id: supervisor.userId },
+ attributes: ['id']
+ });
+ if (supervisorUser) {
+ await notifyUser(supervisorUser.id, {
+ tr: 'falukant.church.application.received',
+ characterId: character.id,
+ officeTypeId: officeTypeId
+ });
+ }
+
+ return application;
+ }
+
+ async getSupervisedApplications(hashedUserId) {
+ // Liefert alle Bewerbungen, über die der User als Vorgesetzter entscheiden kann
+ const user = await getFalukantUserOrFail(hashedUserId);
+ const character = await FalukantCharacter.findOne({
+ where: { userId: user.id },
+ attributes: ['id']
+ });
+ if (!character) {
+ return [];
+ }
+
+ const applications = await ChurchApplication.findAll({
+ where: {
+ supervisorId: character.id,
+ status: 'pending'
+ },
+ include: [
+ {
+ model: ChurchOfficeType,
+ as: 'officeType',
+ attributes: ['name', 'hierarchyLevel']
+ },
+ {
+ model: RegionData,
+ as: 'region',
+ attributes: ['name']
+ },
+ {
+ model: FalukantCharacter,
+ as: 'applicant',
+ attributes: ['id', 'gender', 'age'],
+ include: [
+ {
+ model: FalukantPredefineFirstname,
+ as: 'definedFirstName',
+ attributes: ['name']
+ },
+ {
+ model: FalukantPredefineLastname,
+ as: 'definedLastName',
+ attributes: ['name']
+ },
+ {
+ model: TitleOfNobility,
+ as: 'nobleTitle',
+ attributes: ['labelTr']
+ }
+ ]
+ }
+ ],
+ order: [['createdAt', 'DESC']]
+ });
+
+ return applications.map(app => {
+ const a = app.get({ plain: true });
+ return {
+ id: a.id,
+ officeType: {
+ name: a.officeType.name,
+ hierarchyLevel: a.officeType.hierarchyLevel
+ },
+ region: {
+ name: a.region.name
+ },
+ applicant: {
+ id: a.applicant.id,
+ name: `${a.applicant.definedFirstName?.name || ''} ${a.applicant.definedLastName?.name || ''}`.trim(),
+ gender: a.applicant.gender,
+ age: a.applicant.age,
+ title: a.applicant.nobleTitle?.labelTr
+ },
+ createdAt: a.createdAt
+ };
+ });
+ }
+
+ async decideOnChurchApplication(hashedUserId, applicationId, decision) {
+ // Entscheidung über eine Bewerbung (approve/reject)
+ const user = await getFalukantUserOrFail(hashedUserId);
+ const character = await FalukantCharacter.findOne({
+ where: { userId: user.id },
+ attributes: ['id']
+ });
+ if (!character) {
+ throw new Error('Character not found');
+ }
+
+ const application = await ChurchApplication.findOne({
+ where: {
+ id: applicationId,
+ supervisorId: character.id,
+ status: 'pending'
+ },
+ include: [
+ {
+ model: ChurchOfficeType,
+ as: 'officeType'
+ }
+ ]
+ });
+
+ if (!application) {
+ throw new Error('Application not found or already processed');
+ }
+
+ if (decision === 'approve') {
+ // Prüfe, ob noch Platz verfügbar ist
+ const filledPositions = await ChurchOffice.count({
+ where: {
+ officeTypeId: application.officeTypeId,
+ regionId: application.regionId
+ }
+ });
+
+ if (filledPositions >= application.officeType.seatsPerRegion) {
+ throw new Error('No available seats');
+ }
+
+ // Erstelle kirchliches Amt
+ await ChurchOffice.create({
+ officeTypeId: application.officeTypeId,
+ characterId: application.characterId,
+ regionId: application.regionId,
+ supervisorId: character.id
+ });
+
+ // Aktualisiere Bewerbung
+ application.status = 'approved';
+ application.decisionDate = new Date();
+ await application.save();
+
+ // Benachrichtige Bewerber
+ const applicantCharacter = await FalukantCharacter.findByPk(application.characterId);
+ if (applicantCharacter && applicantCharacter.userId) {
+ await notifyUser(applicantCharacter.userId, {
+ tr: 'falukant.church.application.approved',
+ officeTypeId: application.officeTypeId
+ });
+ }
+
+ // Wenn User bereits ein niedrigeres Amt hatte, entferne es
+ const lowerOffice = await ChurchOffice.findOne({
+ where: {
+ characterId: application.characterId,
+ officeTypeId: {
+ [Op.ne]: application.officeTypeId
+ }
+ },
+ include: [{
+ model: ChurchOfficeType,
+ as: 'type',
+ attributes: ['hierarchyLevel']
+ }]
+ });
+
+ if (lowerOffice && lowerOffice.type.hierarchyLevel < application.officeType.hierarchyLevel) {
+ await lowerOffice.destroy();
+ }
+
+ } else if (decision === 'reject') {
+ application.status = 'rejected';
+ application.decisionDate = new Date();
+ await application.save();
+
+ // Benachrichtige Bewerber
+ const applicantCharacter = await FalukantCharacter.findByPk(application.characterId);
+ if (applicantCharacter && applicantCharacter.userId) {
+ await notifyUser(applicantCharacter.userId, {
+ tr: 'falukant.church.application.rejected',
+ officeTypeId: application.officeTypeId
+ });
+ }
+ } else {
+ throw new Error('Invalid decision');
+ }
+
+ return application;
+ }
+
+ async hasChurchCareer(hashedUserId) {
+ // Prüft, ob der User eine kirchliche Karriere hat
+ const user = await getFalukantUserOrFail(hashedUserId);
+ const character = await FalukantCharacter.findOne({
+ where: { userId: user.id },
+ attributes: ['id']
+ });
+ if (!character) {
+ return false;
+ }
+
+ const churchOffice = await ChurchOffice.findOne({
+ where: { characterId: character.id }
+ });
+
+ return !!churchOffice;
+ }
}
diff --git a/backend/utils/falukant/initializeFalukantTypes.js b/backend/utils/falukant/initializeFalukantTypes.js
index c86278b..8de4c5f 100644
--- a/backend/utils/falukant/initializeFalukantTypes.js
+++ b/backend/utils/falukant/initializeFalukantTypes.js
@@ -16,6 +16,8 @@ import ReputationActionType from "../../models/falukant/type/reputation_action.j
import VehicleType from "../../models/falukant/type/vehicle.js";
import LearnRecipient from "../../models/falukant/type/learn_recipient.js";
import PoliticalOfficeType from "../../models/falukant/type/political_office_type.js";
+import ChurchOfficeType from "../../models/falukant/type/church_office_type.js";
+import ChurchOfficeRequirement from "../../models/falukant/predefine/church_office_requirement.js";
import PoliticalOfficeBenefitType from "../../models/falukant/type/political_office_benefit_type.js";
import PoliticalOfficePrerequisite from "../../models/falukant/predefine/political_office_prerequisite.js";
import UndergroundType from "../../models/falukant/type/underground.js";
@@ -47,6 +49,8 @@ export const initializeFalukantTypes = async () => {
await initializePoliticalOfficeBenefitTypes();
await initializePoliticalOfficeTypes();
await initializePoliticalOfficePrerequisites();
+ await initializeChurchOfficeTypes();
+ await initializeChurchOfficePrerequisites();
await initializeUndergroundTypes();
await initializeVehicleTypes();
await initializeFalukantWeatherTypes();
@@ -1024,6 +1028,119 @@ export const initializePoliticalOfficePrerequisites = async () => {
console.log(`[Falukant] OfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`);
};
+// — Church Offices —
+
+const churchOffices = [
+ { tr: "village-priest", seatsPerRegion: 1, regionType: "city", hierarchyLevel: 1 },
+ { tr: "parish-priest", seatsPerRegion: 1, regionType: "city", hierarchyLevel: 2 },
+ { tr: "dean", seatsPerRegion: 1, regionType: "county", hierarchyLevel: 3 },
+ { tr: "archdeacon", seatsPerRegion: 1, regionType: "shire", hierarchyLevel: 4 },
+ { tr: "bishop", seatsPerRegion: 1, regionType: "markgravate", hierarchyLevel: 5 },
+ { tr: "archbishop", seatsPerRegion: 1, regionType: "duchy", hierarchyLevel: 6 },
+ { tr: "cardinal", seatsPerRegion: 3, regionType: "country", hierarchyLevel: 7 },
+ { tr: "pope", seatsPerRegion: 1, regionType: "country", hierarchyLevel: 8 }
+];
+
+const churchOfficePrerequisites = [
+ {
+ officeTr: "village-priest",
+ prerequisite: {
+ prerequisiteOfficeTypeId: null // Einstiegsposition, keine Voraussetzung
+ }
+ },
+ {
+ officeTr: "parish-priest",
+ prerequisite: {
+ prerequisiteOfficeTypeId: "village-priest"
+ }
+ },
+ {
+ officeTr: "dean",
+ prerequisite: {
+ prerequisiteOfficeTypeId: "parish-priest"
+ }
+ },
+ {
+ officeTr: "archdeacon",
+ prerequisite: {
+ prerequisiteOfficeTypeId: "dean"
+ }
+ },
+ {
+ officeTr: "bishop",
+ prerequisite: {
+ prerequisiteOfficeTypeId: "archdeacon"
+ }
+ },
+ {
+ officeTr: "archbishop",
+ prerequisite: {
+ prerequisiteOfficeTypeId: "bishop"
+ }
+ },
+ {
+ officeTr: "cardinal",
+ prerequisite: {
+ prerequisiteOfficeTypeId: "archbishop"
+ }
+ },
+ {
+ officeTr: "pope",
+ prerequisite: {
+ prerequisiteOfficeTypeId: "cardinal"
+ }
+ }
+];
+
+export const initializeChurchOfficeTypes = async () => {
+ for (const co of churchOffices) {
+ await ChurchOfficeType.findOrCreate({
+ where: { name: co.tr },
+ defaults: {
+ seatsPerRegion: co.seatsPerRegion,
+ regionType: co.regionType,
+ hierarchyLevel: co.hierarchyLevel
+ }
+ });
+ }
+ console.log(`[Falukant] ChurchOfficeTypes initialized`);
+};
+
+export const initializeChurchOfficePrerequisites = async () => {
+ let created = 0;
+ let existing = 0;
+ let skipped = 0;
+ for (const prereq of churchOfficePrerequisites) {
+ const office = await ChurchOfficeType.findOne({ where: { name: prereq.officeTr } });
+ if (!office) { skipped++; continue; }
+
+ let prerequisiteOfficeTypeId = null;
+ if (prereq.prerequisite.prerequisiteOfficeTypeId) {
+ const prerequisiteOffice = await ChurchOfficeType.findOne({
+ where: { name: prereq.prerequisite.prerequisiteOfficeTypeId }
+ });
+ if (prerequisiteOffice) {
+ prerequisiteOfficeTypeId = prerequisiteOffice.id;
+ }
+ }
+
+ try {
+ const [record, wasCreated] = await ChurchOfficeRequirement.findOrCreate({
+ where: { officeTypeId: office.id },
+ defaults: {
+ officeTypeId: office.id,
+ prerequisiteOfficeTypeId: prerequisiteOfficeTypeId
+ }
+ });
+ if (wasCreated) created++; else existing++;
+ } catch (e) {
+ if (falukantDebug) console.error('[Falukant] ChurchOfficePrereq Fehler', { officeId: office?.id, error: e.message });
+ throw e;
+ }
+ }
+ console.log(`[Falukant] ChurchOfficePrereq neu=${created} exist=${existing}${skipped?` skip=${skipped}`:''}`);
+};
+
export const initializeUndergroundTypes = async () => {
for (const underground of undergroundTypes) {
await UndergroundType.findOrCreate({
diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json
index b2634ce..29db5b5 100644
--- a/frontend/src/i18n/locales/de/falukant.json
+++ b/frontend/src/i18n/locales/de/falukant.json
@@ -837,6 +837,58 @@
}
},
"church": {
+ "title": "Kirche",
+ "tabs": {
+ "current": "Aktuelle Positionen",
+ "available": "Verfügbare Positionen",
+ "applications": "Bewerbungen"
+ },
+ "current": {
+ "office": "Amt",
+ "region": "Region",
+ "holder": "Inhaber",
+ "supervisor": "Vorgesetzter",
+ "none": "Keine aktuellen Positionen vorhanden."
+ },
+ "available": {
+ "office": "Amt",
+ "region": "Region",
+ "supervisor": "Vorgesetzter",
+ "seats": "Verfügbare Plätze",
+ "action": "Aktion",
+ "apply": "Bewerben",
+ "applySuccess": "Bewerbung erfolgreich eingereicht.",
+ "applyError": "Fehler beim Einreichen der Bewerbung.",
+ "none": "Keine verfügbaren Positionen."
+ },
+ "applications": {
+ "office": "Amt",
+ "region": "Region",
+ "applicant": "Bewerber",
+ "date": "Datum",
+ "action": "Aktion",
+ "approve": "Annehmen",
+ "reject": "Ablehnen",
+ "approveSuccess": "Bewerbung angenommen.",
+ "rejectSuccess": "Bewerbung abgelehnt.",
+ "decideError": "Fehler bei der Entscheidung.",
+ "none": "Keine Bewerbungen vorhanden."
+ },
+ "offices": {
+ "village-priest": "Dorfgeistlicher",
+ "parish-priest": "Pfarrer",
+ "dean": "Dekan",
+ "archdeacon": "Erzdiakon",
+ "bishop": "Bischof",
+ "archbishop": "Erzbischof",
+ "cardinal": "Kardinal",
+ "pope": "Papst"
+ },
+ "application": {
+ "received": "Neue Bewerbung erhalten",
+ "approved": "Bewerbung angenommen",
+ "rejected": "Bewerbung abgelehnt"
+ },
"title": "Kirche",
"baptism": {
"title": "Taufen",
diff --git a/frontend/src/i18n/locales/en/falukant.json b/frontend/src/i18n/locales/en/falukant.json
index 92caa9b..082fbe4 100644
--- a/frontend/src/i18n/locales/en/falukant.json
+++ b/frontend/src/i18n/locales/en/falukant.json
@@ -376,6 +376,76 @@
"assessor": "Assessor"
}
},
+ "church": {
+ "title": "Church",
+ "tabs": {
+ "current": "Current Positions",
+ "available": "Available Positions",
+ "applications": "Applications"
+ },
+ "current": {
+ "office": "Office",
+ "region": "Region",
+ "holder": "Holder",
+ "supervisor": "Supervisor",
+ "none": "No current positions available."
+ },
+ "available": {
+ "office": "Office",
+ "region": "Region",
+ "supervisor": "Supervisor",
+ "seats": "Available Seats",
+ "action": "Action",
+ "apply": "Apply",
+ "applySuccess": "Application submitted successfully.",
+ "applyError": "Error submitting application.",
+ "none": "No available positions."
+ },
+ "applications": {
+ "office": "Office",
+ "region": "Region",
+ "applicant": "Applicant",
+ "date": "Date",
+ "action": "Action",
+ "approve": "Approve",
+ "reject": "Reject",
+ "approveSuccess": "Application approved.",
+ "rejectSuccess": "Application rejected.",
+ "decideError": "Error making decision.",
+ "none": "No applications available."
+ },
+ "offices": {
+ "village-priest": "Village Priest",
+ "parish-priest": "Parish Priest",
+ "dean": "Dean",
+ "archdeacon": "Archdeacon",
+ "bishop": "Bishop",
+ "archbishop": "Archbishop",
+ "cardinal": "Cardinal",
+ "pope": "Pope"
+ },
+ "application": {
+ "received": "New application received",
+ "approved": "Application approved",
+ "rejected": "Application rejected"
+ },
+ "baptism": {
+ "title": "Baptism",
+ "table": {
+ "name": "First Name",
+ "gender": "Gender",
+ "age": "Age",
+ "baptise": "Baptize (50)",
+ "newName": "Suggest Name"
+ },
+ "gender": {
+ "male": "Boy",
+ "female": "Girl"
+ },
+ "success": "The child has been baptized.",
+ "error": "The child could not be baptized."
+ }
+ },
"family": {
"children": {
"title": "Children",
diff --git a/frontend/src/views/falukant/ChurchView.vue b/frontend/src/views/falukant/ChurchView.vue
index b1651d5..109ac46 100644
--- a/frontend/src/views/falukant/ChurchView.vue
+++ b/frontend/src/views/falukant/ChurchView.vue
@@ -6,6 +6,7 @@
+
{{ $t('falukant.church.baptism.title') }}
+
+
+
+
{{ $t('loading') }}
+
+
+
+
+
+
{{ $t('loading') }}
+
+
+
+
+
+
{{ $t('loading') }}
+
+
@@ -61,12 +180,36 @@ export default {
activeTab: 'baptism',
tabs: [
{ value: 'baptism', label: 'falukant.church.baptism.title' },
+ { value: 'current', label: 'falukant.church.tabs.current' },
+ { value: 'available', label: 'falukant.church.tabs.available' },
+ { value: 'applications', label: 'falukant.church.tabs.applications' },
],
- baptismList: []
+ baptismList: [],
+ currentPositions: [],
+ availablePositions: [],
+ supervisedApplications: [],
+ ownCharacterId: null,
+ loading: {
+ current: false,
+ available: false,
+ applications: false
+ }
}
},
async mounted() {
- await this.loadNotBaptisedChildren()
+ await this.loadNotBaptisedChildren();
+ await this.loadOwnCharacterId();
+ },
+ watch: {
+ activeTab(newTab) {
+ if (newTab === 'current') {
+ this.loadCurrentPositions();
+ } else if (newTab === 'available') {
+ this.loadAvailablePositions();
+ } else if (newTab === 'applications') {
+ this.loadSupervisedApplications();
+ }
+ }
},
methods: {
async loadNotBaptisedChildren() {
@@ -99,6 +242,102 @@ export default {
console.error(err)
this.$root.$refs.errorDialog.open('tr:falukant.church.baptism.error')
}
+ },
+ async loadCurrentPositions() {
+ this.loading.current = true;
+ try {
+ const { data } = await apiClient.get('/api/falukant/church/overview');
+ this.currentPositions = data;
+ } catch (err) {
+ console.error('Error loading current positions', err);
+ } finally {
+ this.loading.current = false;
+ }
+ },
+ async loadAvailablePositions() {
+ this.loading.available = true;
+ try {
+ const { data } = await apiClient.get('/api/falukant/church/positions/available');
+ this.availablePositions = data;
+ } catch (err) {
+ console.error('Error loading available positions', err);
+ } finally {
+ this.loading.available = false;
+ }
+ },
+ async loadSupervisedApplications() {
+ this.loading.applications = true;
+ try {
+ const { data } = await apiClient.get('/api/falukant/church/applications/supervised');
+ this.supervisedApplications = data;
+ } catch (err) {
+ console.error('Error loading supervised applications', err);
+ } finally {
+ this.loading.applications = false;
+ }
+ },
+ async loadOwnCharacterId() {
+ try {
+ const { data } = await apiClient.get('/api/falukant/info');
+ if (data.character && data.character.id) {
+ this.ownCharacterId = data.character.id;
+ }
+ } catch (err) {
+ console.error('Error loading own character ID', err);
+ }
+ },
+ isOwnPosition(pos) {
+ if (!this.ownCharacterId || !pos.character) {
+ return false;
+ }
+ return pos.character.id === this.ownCharacterId;
+ },
+ async applyForPosition(position) {
+ try {
+ const regionId = position.regionId || position.region?.id;
+
+ if (!regionId) {
+ throw new Error('Region not found');
+ }
+
+ await apiClient.post('/api/falukant/church/positions/apply', {
+ officeTypeId: position.id,
+ regionId: regionId
+ });
+
+ this.$root.$refs.messageDialog?.open('tr:falukant.church.available.applySuccess');
+ await this.loadAvailablePositions();
+ } catch (err) {
+ console.error('Error applying for position', err);
+ const errorMsg = err.response?.data?.message || 'falukant.church.available.applyError';
+ this.$root.$refs.errorDialog?.open(`tr:${errorMsg}`);
+ }
+ },
+ async decideOnApplication(applicationId, decision) {
+ try {
+ await apiClient.post('/api/falukant/church/applications/decide', {
+ applicationId: applicationId,
+ decision: decision
+ });
+
+ const msgKey = decision === 'approve'
+ ? 'falukant.church.applications.approveSuccess'
+ : 'falukant.church.applications.rejectSuccess';
+ this.$root.$refs.messageDialog?.open(`tr:${msgKey}`);
+ await this.loadSupervisedApplications();
+ await this.loadCurrentPositions();
+ } catch (err) {
+ console.error('Error deciding on application', err);
+ const errorMsg = err.response?.data?.message || 'falukant.church.applications.decideError';
+ this.$root.$refs.errorDialog?.open(`tr:${errorMsg}`);
+ }
+ },
+ formatDate(date) {
+ return new Date(date).toLocaleDateString(this.$i18n.locale, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit'
+ });
}
}
}
@@ -140,4 +379,75 @@ input[type="text"] {
th {
text-align: left;
}
+
+.tab-pane {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.table-scroll {
+ flex: 1;
+ overflow-y: auto;
+ border: 1px solid #ddd;
+}
+
+.church-table {
+ border-collapse: collapse;
+ width: 100%;
+}
+
+.church-table thead th {
+ position: sticky;
+ top: 0;
+ background: #FFF;
+ z-index: 1;
+ padding: 8px;
+ border: 1px solid #ddd;
+ text-align: left;
+}
+
+.church-table tbody td {
+ padding: 8px;
+ border: 1px solid #ddd;
+}
+
+.church-table tbody tr.own-position {
+ background-color: #e0e0e0;
+ font-weight: bold;
+}
+
+.loading {
+ text-align: center;
+ font-style: italic;
+ margin: 20px 0;
+}
+
+.approve-button {
+ background-color: #28a745;
+ color: white;
+ border: none;
+ padding: 4px 8px;
+ margin-right: 4px;
+ cursor: pointer;
+ border-radius: 4px;
+}
+
+.approve-button:hover {
+ background-color: #218838;
+}
+
+.reject-button {
+ background-color: #dc3545;
+ color: white;
+ border: none;
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 4px;
+}
+
+.reject-button:hover {
+ background-color: #c82333;
+}
\ No newline at end of file