diff --git a/backend/app.ts b/backend/app.ts index 1f3465db..96abac14 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -6,11 +6,14 @@ import mongoose from 'mongoose' import swaggerUi from 'swagger-ui-express' import auth from './auth.js' import { errorHandler } from './controller/error.js' -import './db.js' +import { connectDB } from './db.js' import { RegisterRoutes } from './dist/routes.js' import swaggerDocument from './dist/swagger.json' assert { type: 'json' } import i18n from './i18n.js' -import './migrations.js' +import { checkForMigrations } from './migrations.js' + +await connectDB() +await checkForMigrations() const app = express() diff --git a/backend/controller/projectController.ts b/backend/controller/projectController.ts index b6a6b5ad..d7cc8f68 100644 --- a/backend/controller/projectController.ts +++ b/backend/controller/projectController.ts @@ -1,8 +1,8 @@ import { Request as ExRequest } from 'express' import { Body, Delete, Get, Post, Queries, Query, Request, Route, Security, Tags } from 'tsoa' import { Project as IProject, ProjectSimple, _id } from '../../common/types.js' +import { getSettings } from '../helper.js' import Project from '../models/project.js' -import Settings from '../models/settings.js' import { Controller, GetterQuery, SetterBody } from './controller.js' import { AuthorizationError } from './error.js' @@ -12,7 +12,7 @@ import { AuthorizationError } from './error.js' export class ProjectController extends Controller { @Get() public async getProject(@Queries() query: GetterQuery, @Request() request: ExRequest) { - const settings = (await Settings.findOne().lean())! + const settings = await getSettings() if ( !settings.userCanSeeAllProjects && !request.user?.access['approve/travel'] && diff --git a/backend/controller/uploadController.ts b/backend/controller/uploadController.ts index 96539174..50a4d9b6 100644 --- a/backend/controller/uploadController.ts +++ b/backend/controller/uploadController.ts @@ -4,9 +4,8 @@ import multer from 'multer' import fs from 'node:fs/promises' import { Body, Consumes, Controller, Get, Middlewares, Post, Produces, Query, Request, Route, SuccessResponse, Tags } from 'tsoa' import { _id } from '../../common/types.js' -import { documentFileHandler } from '../helper.js' +import { documentFileHandler, getSettings } from '../helper.js' import i18n from '../i18n.js' -import Settings from '../models/settings.js' import Token from '../models/token.js' import User from '../models/user.js' import { AuthorizationError, NotFoundError } from './error.js' @@ -28,8 +27,13 @@ export class UploadController extends Controller { @Middlewares(validateToken) @Produces('text/html') @SuccessResponse(200) - public async uploadPage(@Request() req: ExRequest, @Query() userId: string, @Query() tokenId: string, @Query() ownerId?: string): Promise { - const settings = (await Settings.findOne().lean())! + public async uploadPage( + @Request() req: ExRequest, + @Query() userId: string, + @Query() tokenId: string, + @Query() ownerId?: string + ): Promise { + const settings = await getSettings() const user = await User.findOne({ _id: userId }).lean() const template = await fs.readFile('./templates/upload.ejs', { encoding: 'utf-8' }) const url = new URL(process.env.VITE_BACKEND_URL + '/upload/new') diff --git a/backend/data/settings.json b/backend/data/settings.json index f6af6044..a2310743 100644 --- a/backend/data/settings.json +++ b/backend/data/settings.json @@ -1,48 +1,41 @@ { "accessIcons": { - "user": ["bi-card-list"], - "inWork:expenseReport": ["bi-coin", "bi-plus"], - "inWork:healthCareCost": ["bi-hospital", "bi-plus"], - "appliedFor:travel": ["bi-airplane", "bi-plus"], - "approved:travel": ["bi-airplane", "bi-plus", "bi-calendar-check"], "admin": ["bi-person-fill"], + "appliedFor:travel": ["bi-airplane", "bi-plus"], "approve/travel": ["bi-calendar-check"], - "confirm/healthCareCost": ["bi-hospital", "bi-check-square"], + "approved:travel": ["bi-airplane", "bi-calendar-check", "bi-plus"], + "confirm/healthCareCost": ["bi-check-square", "bi-hospital"], "examine/expenseReport": ["bi-coin", "bi-pencil-square"], "examine/healthCareCost": ["bi-hospital", "bi-pencil-square"], - "examine/travel": ["bi-airplane", "bi-pencil-square"] + "examine/travel": ["bi-airplane", "bi-pencil-square"], + "inWork:expenseReport": ["bi-coin", "bi-plus"], + "inWork:healthCareCost": ["bi-hospital", "bi-plus"], + "user": ["bi-card-list"] }, "defaultAccess": { - "user": true, - "inWork:expenseReport": true, - "inWork:healthCareCost": true, - "appliedFor:travel": true, - "approved:travel": false, "admin": false, + "appliedFor:travel": true, "approve/travel": false, + "approved:travel": false, "confirm/healthCareCost": false, "examine/expenseReport": false, "examine/healthCareCost": false, - "examine/travel": false + "examine/travel": false, + "inWork:expenseReport": true, + "inWork:healthCareCost": true, + "user": true }, - "allowSpouseRefund": false, - "userCanSeeAllProjects": true, - "breakfastCateringLumpSumCut": 0.2, - "dinnerCateringLumpSumCut": 0.4, - "factorCateringLumpSum": 1, - "factorCateringLumpSumExceptions": [], - "factorOvernightLumpSum": 1, - "factorOvernightLumpSumExceptions": [], - "fallBackLumpSumCountry": "LU", - "lunchCateringLumpSumCut": 0.4, - "maxTravelDayCount": 92, - "distanceRefunds": { - "car": 0.3, - "motorcycle": 0.2, - "halfCar": 0.15 + "disableReportType": { + "expenseReport": false, + "healthCareCost": false, + "travel": false + }, + "retentionPolicy": { + "deleteApprovedTravelAfterXDaysUnused": 0, + "deleteInWorkReportsAfterXDaysUnused": 0, + "deleteRefundedAfterXDays": 0, + "mailXDaysBeforeDeletion": 7 }, - "secoundNightOnAirplaneLumpSumCountry": "AT", - "secoundNightOnShipOrFerryLumpSumCountry": "LU", "stateColors": { "appliedFor": { "color": "#cae5ff", @@ -73,21 +66,31 @@ "text": "white" } }, - "toleranceStageDatesToApprovedTravelDates": 3, - "allowTravelApplicationForThePast": false, - "vehicleRegistrationWhenUsingOwnCar": "optional", - "uploadTokenExpireAfterSeconds": 600, - "disableReportType": { - "travel": false, - "expenseReport": false, - "healthCareCost": false - }, - "retentionPolicy": { - "deleteRefundedAfterXDays": 0, - "deleteApprovedTravelAfterXDaysUnused": 0, - "deleteInWorkReportsAfterXDaysUnused": 0, - "mailXDaysBeforeDeletion": 7 + "travelSettings": { + "allowSpouseRefund": false, + "allowTravelApplicationForThePast": false, + "distanceRefunds": { + "car": 0.3, + "halfCar": 0.15, + "motorcycle": 0.2 + }, + "factorCateringLumpSum": 1, + "factorCateringLumpSumExceptions": [], + "factorOvernightLumpSum": 1, + "factorOvernightLumpSumExceptions": [], + "fallBackLumpSumCountry": "LU", + "lumpSumCut": { + "breakfast": 0.2, + "dinner": 0.4, + "lunch": 0.4 + }, + "maxTravelDayCount": 92, + "secoundNightOnAirplaneLumpSumCountry": "AT", + "secoundNightOnShipOrFerryLumpSumCountry": "LU", + "toleranceStageDatesToApprovedTravelDates": 3, + "vehicleRegistrationWhenUsingOwnCar": "optional" }, - - "version": "1.2.0" + "uploadTokenExpireAfterSeconds": 600, + "userCanSeeAllProjects": true, + "version": "1.2.1" } diff --git a/backend/db.ts b/backend/db.ts index 55984435..f8250c61 100644 --- a/backend/db.ts +++ b/backend/db.ts @@ -12,8 +12,6 @@ import HealthInsurance from './models/healthInsurance.js' import Organisation from './models/organisation.js' import Project from './models/project.js' -await connectDB() - export async function connectDB() { const first = mongoose.connection.readyState === 0 if (first) { diff --git a/backend/helper.ts b/backend/helper.ts index aa12ee06..0f93c158 100644 --- a/backend/helper.ts +++ b/backend/helper.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from 'express' import fs from 'fs/promises' -import { Model, Types } from 'mongoose' +import mongoose, { Model, Types } from 'mongoose' import { AnyState, DocumentFile as IDocumentFile, @@ -8,8 +8,10 @@ import { HealthCareCost as IHealthCareCost, Travel as ITravel, reportIsHealthCareCost, - reportIsTravel + reportIsTravel, + Settings } from '../common/types.js' +import { connectDB } from './db.js' import DocumentFile from './models/documentFile.js' import ExpenseReport from './models/expenseReport.js' import HealthCareCost from './models/healthCareCost.js' @@ -174,3 +176,13 @@ export async function getDateOfSubmission( } return null } + +export async function getSettings(): Promise { + await connectDB() + const settings = (await mongoose.connection.collection('settings').findOne()) as Settings | null + if (settings) { + return settings + } else { + throw Error('Settings not found') + } +} diff --git a/backend/migrations.ts b/backend/migrations.ts index 44ca874b..59fe1c77 100644 --- a/backend/migrations.ts +++ b/backend/migrations.ts @@ -8,181 +8,208 @@ import Settings from './models/settings.js' import Travel from './models/travel.js' import User from './models/user.js' -const settings = await Settings.findOne() -if (settings?.migrateFrom) { - migrate(settings.migrateFrom) -} - -async function migrate(from: string) { - switch (from) { - case '0.3.0': { - console.log('Apply migration from v0.3.0') - var randomOrg = await Organisation.findOne() - if (!randomOrg) { - randomOrg = (await new Organisation({ name: 'My Organisation' }).save()).toObject() - } - const travels = await Travel.find() - const healthCareCosts = await HealthCareCost.find() - const expenseReports = await ExpenseReport.find() - for (const travel of travels) { - //@ts-ignore - if (!travel.organisation) { +export async function checkForMigrations() { + const settings = await Settings.findOne() + if (settings?.migrateFrom) { + switch (settings.migrateFrom) { + case '0.3.0': { + console.log('Apply migration from v0.3.0') + var randomOrg = await Organisation.findOne() + if (!randomOrg) { + randomOrg = (await new Organisation({ name: 'My Organisation' }).save()).toObject() + } + const travels = await Travel.find() + const healthCareCosts = await HealthCareCost.find() + const expenseReports = await ExpenseReport.find() + for (const travel of travels) { //@ts-ignore - if (travel.traveler) { + if (!travel.organisation) { //@ts-ignore - const user = await User.findOne({ _id: travel.traveler._id }).lean() //@ts-ignore - var org = user?.settings.organisation - if (!org) { - org = randomOrg - } //@ts-ignore - travel.organisation = org - travel.save() - } else { - await travel.deleteOne() + if (travel.traveler) { + //@ts-ignore + const user = await User.findOne({ _id: travel.traveler._id }).lean() //@ts-ignore + var org = user?.settings.organisation + if (!org) { + org = randomOrg + } //@ts-ignore + travel.organisation = org + travel.save() + } else { + await travel.deleteOne() + } } } - } - for (const healthCareCost of healthCareCosts) { - //@ts-ignore - if (!healthCareCost.organisation) { + for (const healthCareCost of healthCareCosts) { //@ts-ignore - if (healthCareCost.applicant) { + if (!healthCareCost.organisation) { //@ts-ignore - const user = await User.findOne({ _id: healthCareCost.applicant._id }).lean() //@ts-ignore - var org = user?.settings.organisation - if (!org) { - org = randomOrg - } //@ts-ignore - healthCareCost.organisation = org - healthCareCost.save() - } else { - await healthCareCost.deleteOne() + if (healthCareCost.applicant) { + //@ts-ignore + const user = await User.findOne({ _id: healthCareCost.applicant._id }).lean() //@ts-ignore + var org = user?.settings.organisation + if (!org) { + org = randomOrg + } //@ts-ignore + healthCareCost.organisation = org + healthCareCost.save() + } else { + await healthCareCost.deleteOne() + } } } - } - for (const expenseReport of expenseReports) { - //@ts-ignore - if (!expenseReport.organisation) { + for (const expenseReport of expenseReports) { //@ts-ignore - if (expenseReport.expensePayer) { + if (!expenseReport.organisation) { //@ts-ignore - const user = await User.findOne({ _id: expenseReport.expensePayer._id }).lean() //@ts-ignore - var org = user?.settings.organisation - if (!org) { - org = randomOrg - } //@ts-ignore - expenseReport.organisation = org - expenseReport.save() - } else { - await expenseReport.deleteOne() + if (expenseReport.expensePayer) { + //@ts-ignore + const user = await User.findOne({ _id: expenseReport.expensePayer._id }).lean() //@ts-ignore + var org = user?.settings.organisation + if (!org) { + org = randomOrg + } //@ts-ignore + expenseReport.organisation = org + expenseReport.save() + } else { + await expenseReport.deleteOne() + } } } } - } - case '0.3.2': { - console.log('Apply migration from v0.3.2') - const allTravels = mongoose.connection.collection('travels').find() - for await (const travel of allTravels) { - for (const stage of travel.stages) { - stage.transport = { - type: stage.transport, - distance: stage.distance, - distanceRefundType: stage.transport === 'ownCar' ? distanceRefundTypes[0] : null + case '0.3.2': { + console.log('Apply migration from v0.3.2') + const allTravels = mongoose.connection.collection('travels').find() + for await (const travel of allTravels) { + for (const stage of travel.stages) { + stage.transport = { + type: stage.transport, + distance: stage.distance, + distanceRefundType: stage.transport === 'ownCar' ? distanceRefundTypes[0] : null + } } + mongoose.connection.collection('travels').updateOne({ _id: travel._id }, { $set: { stages: travel.stages } }) } - mongoose.connection.collection('travels').updateOne({ _id: travel._id }, { $set: { stages: travel.stages } }) } - } - case '0.3.3': { - console.log('Apply migration from v0.3.3') - await mongoose.connection.collection('countries').drop() - initDB() - } - case '0.3.4': { - console.log('Apply migration from v0.3.4') - mongoose.connection.collection('users').updateMany({}, { $set: { 'access.user': true } }) - } - case '0.3.5': { - console.log('Apply migration from v0.3.5') - const travels = await Travel.find() - for (const travel of travels) { - if (travel.state === 'refunded' || travel.state === 'underExamination') { - //@ts-ignore - travel.lastPlaceOfWork = { country: travel.stages[travel.stages.length - 1].endLocation.country } - } else { - //@ts-ignore - travel.lastPlaceOfWork = { country: travel.destinationPlace.country } + case '0.3.3': { + console.log('Apply migration from v0.3.3') + await mongoose.connection.collection('countries').drop() + initDB() + } + case '0.3.4': { + console.log('Apply migration from v0.3.4') + mongoose.connection.collection('users').updateMany({}, { $set: { 'access.user': true } }) + } + case '0.3.5': { + console.log('Apply migration from v0.3.5') + const travels = await Travel.find() + for (const travel of travels) { + if (travel.state === 'refunded' || travel.state === 'underExamination') { + //@ts-ignore + travel.lastPlaceOfWork = { country: travel.stages[travel.stages.length - 1].endLocation.country } + } else { + //@ts-ignore + travel.lastPlaceOfWork = { country: travel.destinationPlace.country } + } + try { + await travel.save() + } catch (error: any) { + console.error( + 'Failed migrating travel: ' + travel._id, + Object.values(error.errors).map((val: any) => val.message) + ) + } } - try { - await travel.save() - } catch (error: any) { - console.error( - 'Failed migrating travel: ' + travel._id, - Object.values(error.errors).map((val: any) => val.message) - ) + } + case '0.3.7': { + console.log('Apply migration from v0.3.7') + mongoose.connection.collection('travels').updateMany({}, { $rename: { traveler: 'owner' } }) + mongoose.connection.collection('expensereports').updateMany({}, { $rename: { expensePayer: 'owner' } }) + mongoose.connection.collection('healthcarecosts').updateMany({}, { $rename: { applicant: 'owner' } }) + } + case '0.3.9': { + console.log('Apply migration from v0.3.9') + const project = await mongoose.connection.collection('projects').findOne({}) + if (project) { + mongoose.connection.collection('travels').updateMany({}, { $set: { project: project._id } }) + mongoose.connection.collection('expensereports').updateMany({}, { $set: { project: project._id } }) + mongoose.connection.collection('healthcarecosts').updateMany({}, { $set: { project: project._id } }) + } else { + throw new Error('No project found') } } - } - case '0.3.7': { - console.log('Apply migration from v0.3.7') - mongoose.connection.collection('travels').updateMany({}, { $rename: { traveler: 'owner' } }) - mongoose.connection.collection('expensereports').updateMany({}, { $rename: { expensePayer: 'owner' } }) - mongoose.connection.collection('healthcarecosts').updateMany({}, { $rename: { applicant: 'owner' } }) - } - case '0.3.9': { - console.log('Apply migration from v0.3.9') - const project = await mongoose.connection.collection('projects').findOne({}) - if (project) { - mongoose.connection.collection('travels').updateMany({}, { $set: { project: project._id } }) - mongoose.connection.collection('expensereports').updateMany({}, { $set: { project: project._id } }) - mongoose.connection.collection('healthcarecosts').updateMany({}, { $set: { project: project._id } }) - } else { - throw new Error('No project found') + case '0.3.10': { + console.log('Apply migration from v0.3.10: Set default access settings') + mongoose.connection.collection('users').updateMany( + {}, + { + $set: { + 'access.inWork:expenseReport': true, + 'access.inWork:healthCareCost': true, + 'access.appliedFor:travel': true, + 'access.approved:travel': false + } + } + ) } - } - case '0.3.10': { - console.log('Apply migration from v0.3.10: Set default access settings') - mongoose.connection.collection('users').updateMany( - {}, - { - $set: { - 'access.inWork:expenseReport': true, - 'access.inWork:healthCareCost': true, - 'access.appliedFor:travel': true, - 'access.approved:travel': false + case '0.4.0': { + console.log('Apply migration from v0.4.0: Reload Countries (fix typo)') + await mongoose.connection.collection('countries').drop() + await initDB() + } + case '1.1.0': { + console.log('Apply migration from v1.1.0: Rewrite history dates to reflect submission date') + async function rewriteSubmissionDate(collection: string, state: string) { + const allReports = mongoose.connection.collection(collection).find({ historic: false }) + for await (const report of allReports) { + for (var i = 0; i < report.history.length; i++) { + const history = await mongoose.connection.collection(collection).findOne({ _id: report.history[i] }) + if (history && history.state === state) { + let submissionDate = report.updatedAt + mongoose.connection.collection(collection).updateOne({ _id: report.history[i] }, { $set: { updatedAt: submissionDate } }) + break + } + } } } - ) - } - case '0.4.0': { - console.log('Apply migration from v0.4.0: Reload Countries (fix typo)') - await mongoose.connection.collection('countries').drop() - await initDB() - } - case '1.1.0': { - console.log('Apply migration from v1.1.0: Rewrite history dates to reflect submission date') - async function rewriteSubmissionDate(collection: string, state: string) { - const allReports = mongoose.connection.collection(collection).find({ historic: false }) - for await (const report of allReports) { - for (var i = 0; i < report.history.length; i++) { - const history = await mongoose.connection.collection(collection).findOne({ _id: report.history[i] }) - if (history && history.state === state) { - let submissionDate = report.updatedAt - mongoose.connection.collection(collection).updateOne({ _id: report.history[i] }, { $set: { updatedAt: submissionDate } }) - break - } + await rewriteSubmissionDate('travels', 'approved') + await rewriteSubmissionDate('expensereports', 'inWork') + await rewriteSubmissionDate('healthcarecosts', 'inWork') + } + case '1.2.0': { + console.log('Apply migration from v1.2.0: New settings structure') + const oldSettings = await mongoose.connection.collection('settings').findOne() + if (settings && oldSettings) { + settings.travelSettings = { + maxTravelDayCount: oldSettings.maxTravelDayCount, + allowSpouseRefund: oldSettings.allowSpouseRefund, + allowTravelApplicationForThePast: oldSettings.allowTravelApplicationForThePast, + toleranceStageDatesToApprovedTravelDates: oldSettings.toleranceStageDatesToApprovedTravelDates, + distanceRefunds: oldSettings.distanceRefunds, + vehicleRegistrationWhenUsingOwnCar: oldSettings.vehicleRegistrationWhenUsingOwnCar, + lumpSumCut: { + breakfast: oldSettings.breakfastCateringLumpSumCut, + lunch: oldSettings.lunchCateringLumpSumCut, + dinner: oldSettings.dinnerCateringLumpSumCut + }, + factorCateringLumpSum: oldSettings.factorCateringLumpSum, + factorCateringLumpSumExceptions: oldSettings.factorCateringLumpSumExceptions, + factorOvernightLumpSum: oldSettings.factorOvernightLumpSum, + factorOvernightLumpSumExceptions: oldSettings.factorOvernightLumpSumExceptions, + fallBackLumpSumCountry: oldSettings.fallBackLumpSumCountry, + secoundNightOnAirplaneLumpSumCountry: oldSettings.secoundNightOnAirplaneLumpSumCountry, + secoundNightOnShipOrFerryLumpSumCountry: oldSettings.secoundNightOnShipOrFerryLumpSumCountry } + await settings.save() + } else { + throw Error("Couldn't find settings") } } - await rewriteSubmissionDate('travels', 'approved') - await rewriteSubmissionDate('expensereports', 'inWork') - await rewriteSubmissionDate('healthcarecosts', 'inWork') + default: + if (settings) { + settings.migrateFrom = undefined + await settings.save() + } + break } - default: - if (settings) { - settings.migrateFrom = undefined - await settings.save() - } - break } } diff --git a/backend/models/country.ts b/backend/models/country.ts index 97709ffd..67806bb9 100644 --- a/backend/models/country.ts +++ b/backend/models/country.ts @@ -1,14 +1,7 @@ -import { HydratedDocument, Model, Schema, model } from 'mongoose' -import { Country, LumpSum } from '../../common/types.js' -import Settings from './settings.js' +import { Schema, model } from 'mongoose' +import { Country } from '../../common/types.js' -interface Methods { - getLumpSum(date: Date, special?: string): Promise -} - -type CountryModel = Model - -export const countrySchema = new Schema({ +export const countrySchema = new Schema({ _id: { type: String, required: true, trim: true, alias: 'code', label: 'labels.code' }, flag: { type: String }, name: { @@ -48,34 +41,4 @@ export const countrySchema = new Schema({ } }) -countrySchema.methods.getLumpSum = async function (date: Date, special: string | undefined = undefined): Promise { - if (this.lumpSumsFrom) { - return (await model('Country').findOne({ _id: this.lumpSumsFrom })).getLumpSum(date) - } else if (this.lumpSums.length == 0) { - const settings = (await Settings.findOne().lean())! - return (await model('Country').findOne({ _id: settings.fallBackLumpSumCountry })).getLumpSum(date) - } else { - var nearest = 0 - for (var i = 0; i < this.lumpSums.length; i++) { - var diff = date.valueOf() - (this.lumpSums[i].validFrom as Date).valueOf() - if (diff >= 0 && diff < date.valueOf() - (this.lumpSums[nearest].validFrom as Date).valueOf()) { - nearest = i - } - } - if (date.valueOf() - (this.lumpSums[nearest].validFrom as Date).valueOf() < 0) { - throw new Error('No valid lumpSum found for Country: ' + this._id + ' for date: ' + date) - } - if (special && this.lumpSums[nearest].specials) { - for (const lumpSumSpecial of this.lumpSums[nearest].specials!) { - if (lumpSumSpecial.city === special) { - return lumpSumSpecial - } - } - } - return this.lumpSums[nearest] - } -} - -export default model('Country', countrySchema) - -export interface CountryDoc extends Methods, HydratedDocument {} +export default model('Country', countrySchema) diff --git a/backend/models/settings.ts b/backend/models/settings.ts index c1374e26..a9a722f8 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -1,9 +1,10 @@ -import { InferSchemaType, Schema, model } from 'mongoose' +import { HydratedDocument, InferSchemaType, Schema, model } from 'mongoose' import { Access, DistanceRefundType, ExpenseReportState, HealthCareCostState, + Meal, ReportType, RetentionType, Settings, @@ -13,11 +14,13 @@ import { distanceRefundTypes, expenseReportStates, healthCareCostStates, + meals, reportTypes, retention, travelStates } from '../../common/types.js' import '../db.js' +import { travelCalculator } from './travel.js' const accessIcons = {} as { [key in Access]: { type: { type: StringConstructor; required: true }[]; required: true; label: string } } for (const access of accesses) { @@ -79,35 +82,48 @@ for (const policy of retention) { } } +const lumpSumCut = {} as { [key in Meal]: { type: NumberConstructor; required: true } } +for (const meal of meals) { + lumpSumCut[meal] = { type: Number, required: true } +} + export const settingsSchema = new Schema({ - allowSpouseRefund: { type: Boolean, required: true }, - allowTravelApplicationForThePast: { type: Boolean, required: true }, - vehicleRegistrationWhenUsingOwnCar: { type: String, enum: ['required', 'optional', 'none'], required: true }, userCanSeeAllProjects: { type: Boolean, required: true }, defaultAccess: { type: defaultAccess, required: true }, disableReportType: { type: disableReportType, required: true }, - maxTravelDayCount: { type: Number, min: 0, required: true }, - toleranceStageDatesToApprovedTravelDates: { type: Number, min: 0, required: true }, - distanceRefunds: { type: distanceRefunds, required: true }, + retentionPolicy: { type: retentionPolicy, required: true }, + travelSettings: { + type: { + maxTravelDayCount: { type: Number, min: 0, required: true }, + allowSpouseRefund: { type: Boolean, required: true }, + allowTravelApplicationForThePast: { type: Boolean, required: true }, + toleranceStageDatesToApprovedTravelDates: { type: Number, min: 0, required: true }, + distanceRefunds: { type: distanceRefunds, required: true }, + vehicleRegistrationWhenUsingOwnCar: { type: String, enum: ['required', 'optional', 'none'], required: true }, + lumpSumCut: { type: lumpSumCut, required: true }, + factorCateringLumpSum: { type: Number, min: 0, max: 1, required: true }, + factorCateringLumpSumExceptions: { type: [{ type: String, ref: 'Country' }], required: true }, + factorOvernightLumpSum: { type: Number, min: 0, max: 1, required: true }, + factorOvernightLumpSumExceptions: { type: [{ type: String, ref: 'Country' }], required: true }, + fallBackLumpSumCountry: { type: String, ref: 'Country', required: true }, + secoundNightOnAirplaneLumpSumCountry: { type: String, ref: 'Country', required: true }, + secoundNightOnShipOrFerryLumpSumCountry: { type: String, ref: 'Country', required: true } + }, + required: true + }, + uploadTokenExpireAfterSeconds: { type: Number, min: 0, required: true }, - breakfastCateringLumpSumCut: { type: Number, min: 0, max: 1, required: true }, - lunchCateringLumpSumCut: { type: Number, min: 0, max: 1, required: true }, - dinnerCateringLumpSumCut: { type: Number, min: 0, max: 1, required: true }, - factorCateringLumpSum: { type: Number, min: 0, max: 1, required: true }, - factorCateringLumpSumExceptions: { type: [{ type: String, ref: 'Country' }], required: true }, - factorOvernightLumpSum: { type: Number, min: 0, max: 1, required: true }, - factorOvernightLumpSumExceptions: { type: [{ type: String, ref: 'Country' }], required: true }, - fallBackLumpSumCountry: { type: String, ref: 'Country', required: true }, - secoundNightOnAirplaneLumpSumCountry: { type: String, ref: 'Country', required: true }, - secoundNightOnShipOrFerryLumpSumCountry: { type: String, ref: 'Country', required: true }, stateColors: { type: stateColors, required: true }, accessIcons: { type: accessIcons, required: true }, version: { type: String, required: true, hide: true }, - migrateFrom: { type: String, hide: true }, - retentionPolicy: { type: retentionPolicy, required: true } + migrateFrom: { type: String, hide: true } }) export type SettingsSchema = InferSchemaType export type ISettings = SettingsSchema & { _id: _id } +settingsSchema.post('save', async function (this: HydratedDocument) { + travelCalculator.updateSettings(this.travelSettings) +}) + export default model('Settings', settingsSchema) diff --git a/backend/models/token.ts b/backend/models/token.ts index ad289239..e334e575 100644 --- a/backend/models/token.ts +++ b/backend/models/token.ts @@ -1,7 +1,7 @@ import { Schema, model } from 'mongoose' -import Settings from './settings.js' +import { getSettings } from '../helper.js' -const settings = (await Settings.findOne().lean())! +const settings = await getSettings() const tokenSchema = new Schema( { diff --git a/backend/models/travel.ts b/backend/models/travel.ts index 6a3a661a..8e7657e0 100644 --- a/backend/models/travel.ts +++ b/backend/models/travel.ts @@ -1,31 +1,32 @@ -import mongoose, { Document, Error, HydratedDocument, Model, Schema, model } from 'mongoose' -import { datetimeToDate, getDayList, getDiffInDays } from '../../common/scripts.js' +import { Document, HydratedDocument, Model, Schema, model } from 'mongoose' +import { TravelCalculator } from '../../common/travel.js' import { - CountrySimple, + CountryCode, + Country as ICountry, Currency as ICurrency, - Meal, Money, - Place, - PurposeSimple, Record, - Refund, - Settings, Travel, TravelComment, - TravelDay, baseCurrency, distanceRefundTypes, lumpsumTypes, transportTypes, travelStates } from '../../common/types.js' -import Country, { CountryDoc } from './country.js' +import { getSettings } from '../helper.js' +import Country from './country.js' import DocumentFile from './documentFile.js' import { convertCurrency, costObject } from './helper.js' import { ProjectDoc } from './project.js' import User from './user.js' -const settings = (await mongoose.connection.collection('settings').findOne({})) as Settings +export const travelCalculator = new TravelCalculator( + (id: CountryCode) => Country.findOne({ _id: id }).lean() as Promise, + (await getSettings()).travelSettings +) + +const settings = await getSettings() function place(required = false, withPlace = true) { const obj: any = { @@ -40,19 +41,8 @@ function place(required = false, withPlace = true) { interface Methods { saveToHistory(): Promise - calculateProgress(): void - getDays(): { date: Date; cateringNoRefund?: { [key in Meal]: boolean }; purpose?: PurposeSimple; refunds: Refund[] }[] - getBorderCrossings(): Promise<{ date: Date; country: CountrySimple; special?: string }[]> - getDateOfLastPlaceOfWork(): Date | null - calculateDays(): Promise - addCateringRefunds(): Promise - addOvernightRefunds(): Promise calculateExchangeRates(): Promise - calculateProfessionalShare(): void - calculateRefundforOwnCar(): void addComment(): void - validateDates(): void - validateCountries(): void } type TravelModel = Model @@ -140,7 +130,7 @@ const travelSchema = new Schema( { timestamps: true } ) -if (settings.allowSpouseRefund) { +if (settings.travelSettings.allowSpouseRefund) { travelSchema.add({ claimSpouseRefund: { type: Boolean, default: false }, fellowTravelersNames: { type: String } }) } @@ -219,222 +209,6 @@ travelSchema.methods.saveToHistory = async function (this: TravelDoc) { } } -travelSchema.methods.calculateProgress = function (this: TravelDoc) { - if (this.stages.length > 0) { - var approvedLength = getDiffInDays(this.startDate, this.endDate) + 1 - var stageLength = getDiffInDays(this.stages[0].departure, this.stages[this.stages.length - 1].arrival) + 1 - if (stageLength >= approvedLength) { - this.progress = 100 - } else { - this.progress = Math.round((stageLength / approvedLength) * 100) - } - } else { - this.progress = 0 - } -} - -travelSchema.methods.getDays = function (this: TravelDoc) { - if (this.stages.length > 0) { - const days = getDayList(this.stages[0].departure, this.stages[this.stages.length - 1].arrival) - const newDays: { date: Date; cateringNoRefund?: { [key in Meal]: boolean }; purpose?: PurposeSimple; refunds: Refund[] }[] = days.map( - (d) => { - return { date: d, refunds: [] } - } - ) - for (const oldDay of this.days) { - for (const newDay of newDays) { - if (new Date(oldDay.date).valueOf() - new Date(newDay.date!).valueOf() == 0) { - newDay.cateringNoRefund = oldDay.cateringNoRefund - newDay.purpose = oldDay.purpose - break - } - } - } - return newDays - } else { - return [] - } -} - -travelSchema.methods.getBorderCrossings = async function ( - this: TravelDoc -): Promise<{ date: Date; country: CountrySimple; special?: string }[]> { - if (this.stages.length > 0) { - const startCountry = this.stages[0].startLocation.country - const borderCrossings: { date: Date; country: CountrySimple; special?: string }[] = [ - { date: new Date(this.stages[0].departure), country: startCountry } - ] - for (var i = 0; i < this.stages.length; i++) { - const stage = this.stages[i] - // Country Change (or special change) - if ( - stage.startLocation && - stage.endLocation && - (stage.startLocation.country._id !== stage.endLocation.country._id || stage.startLocation.special !== stage.endLocation.special) - ) { - // More than 1 night - if (getDiffInDays(stage.departure, stage.arrival) > 1) { - if (['ownCar', 'otherTransport'].indexOf(stage.transport.type) !== -1) { - if (stage.midnightCountries) borderCrossings.push(...(stage.midnightCountries as { date: Date; country: CountrySimple }[])) - } else if (stage.transport.type === 'airplane') { - const country = await Country.findOne({ _id: settings.secoundNightOnAirplaneLumpSumCountry }).lean() - if (country) { - borderCrossings.push({ - date: new Date(new Date(stage.departure).valueOf() + 24 * 60 * 60 * 1000), - country - }) - } else { - throw new Error('secoundNightOnAirplaneLumpSumCountry(' + settings.secoundNightOnAirplaneLumpSumCountry + ') not found') - } - } else if (stage.transport.type === 'shipOrFerry') { - const country = await Country.findOne({ _id: settings.secoundNightOnShipOrFerryLumpSumCountry }).lean() - if (country) { - borderCrossings.push({ - date: new Date(new Date(stage.departure).valueOf() + 24 * 60 * 60 * 1000), - country - }) - } else { - throw new Error('secoundNightOnShipOrFerryLumpSumCountry(' + settings.secoundNightOnShipOrFerryLumpSumCountry + ') not found') - } - } - } - borderCrossings.push({ date: new Date(stage.arrival), country: stage.endLocation.country, special: stage.endLocation.special }) - } - } - return borderCrossings - } else { - return [] - } -} - -travelSchema.methods.getDateOfLastPlaceOfWork = function (this: TravelDoc) { - var date: Date | null = null - function sameCountryAndSpecial(placeA: Place, placeB: Place): boolean { - return placeA.country._id === placeB.country._id && placeA.special === placeB.special - } - for (var i = this.stages.length - 1; i >= 0; i--) { - if (sameCountryAndSpecial(this.stages[i].endLocation, this.lastPlaceOfWork)) { - date = datetimeToDate(this.stages[i].arrival) - break - } else if (sameCountryAndSpecial(this.stages[i].startLocation, this.lastPlaceOfWork)) { - date = datetimeToDate(this.stages[i].departure) - break - } - } - return date -} - -travelSchema.methods.calculateDays = async function (this: TravelDoc) { - const borderCrossings = await this.getBorderCrossings() - const days = this.getDays() - for (const borderX of borderCrossings) { - const dbCountry = await Country.findOne({ _id: borderX.country._id }) - if (dbCountry) { - borderX.country = dbCountry - } else { - throw new Error('No Country found with _id: ' + borderX.country._id) - } - } - var bXIndex = 0 - for (const day of days) { - while ( - bXIndex < borderCrossings.length - 1 && - day.date.valueOf() + 1000 * 24 * 60 * 60 - 1 - borderCrossings[bXIndex + 1].date.valueOf() > 0 - ) { - bXIndex++ - } - ;(day as Partial).country = borderCrossings[bXIndex].country - ;(day as Partial).special = borderCrossings[bXIndex].special - } - - // change days according to last place of work - const dateOfLastPlaceOfWork = this.getDateOfLastPlaceOfWork() - - if (dateOfLastPlaceOfWork) { - const dbCountry = await Country.findOne({ _id: this.lastPlaceOfWork.country._id }) - if (!dbCountry) { - throw new Error('No Country found with _id: ' + this.lastPlaceOfWork.country._id) - } - for (const day of days) { - if (day.date.valueOf() >= dateOfLastPlaceOfWork.valueOf()) { - ;(day as Partial).country = dbCountry - ;(day as Partial).special = this.lastPlaceOfWork.special - } - } - } - - this.days = days as TravelDay[] -} - -travelSchema.methods.addCateringRefunds = async function (this: TravelDoc) { - for (var i = 0; i < this.days.length; i++) { - const day = this.days[i] - if (day.purpose == 'professional') { - const result: Partial = { type: 'catering24' } - if (i == 0 || i == this.days.length - 1) { - result.type = 'catering8' - } - var amount = (await (day.country as CountryDoc).getLumpSum(day.date as Date, day.special))[result.type!] - var leftover = 1 - if (day.cateringNoRefund.breakfast) leftover -= settings.breakfastCateringLumpSumCut - if (day.cateringNoRefund.lunch) leftover -= settings.lunchCateringLumpSumCut - if (day.cateringNoRefund.dinner) leftover -= settings.dinnerCateringLumpSumCut - - result.refund = { - amount: - Math.round( - amount * - leftover * - ((settings.factorCateringLumpSumExceptions as string[]).indexOf(day.country._id) == -1 ? settings.factorCateringLumpSum : 1) * - 100 - ) / 100 - } - if (settings.allowSpouseRefund && this.claimSpouseRefund) { - result.refund.amount! *= 2 - } - day.refunds.push(result as Refund) - } - } -} - -travelSchema.methods.addOvernightRefunds = async function (this: TravelDoc) { - if (this.claimOvernightLumpSum) { - var stageIndex = 0 - for (var i = 0; i < this.days.length; i++) { - const day = this.days[i] - if (day.purpose == 'professional') { - if (i == this.days.length - 1) { - break - } - var midnight = (day.date as Date).valueOf() + 1000 * 24 * 60 * 60 - 1 - while (stageIndex < this.stages.length - 1 && midnight - new Date(this.stages[stageIndex].arrival).valueOf() > 0) { - stageIndex++ - } - if ( - midnight - new Date(this.stages[stageIndex].departure).valueOf() > 0 && - new Date(this.stages[stageIndex].arrival).valueOf() - midnight > 0 - ) { - continue - } - const result: Partial = { type: 'overnight' } - var amount = (await (day.country as CountryDoc).getLumpSum(day.date as Date, day.special))[result.type!] - result.refund = { - amount: - Math.round( - amount * - (settings.factorOvernightLumpSumExceptions.indexOf(day.country._id) == -1 ? settings.factorOvernightLumpSum : 1) * - 100 - ) / 100 - } - if (settings.allowSpouseRefund && this.claimSpouseRefund) { - result.refund.amount! *= 2 - } - day.refunds.push(result as Refund) - } - } - } -} - async function exchange(costObject: Money, date: string | number | Date) { var exchangeRate = null @@ -474,40 +248,6 @@ travelSchema.methods.calculateExchangeRates = async function (this: TravelDoc) { } } -travelSchema.methods.calculateProfessionalShare = function (this: TravelDoc) { - if (this.days.length > 0) { - var professionalDays = 0 - var calc = false - for (const day of this.days) { - if (day.purpose === 'professional') { - professionalDays += 1 - } else { - calc = true - } - } - if (calc) { - this.professionalShare = professionalDays / this.days.length - } else { - this.professionalShare = 1 - } - } else { - this.professionalShare = null - } -} - -travelSchema.methods.calculateRefundforOwnCar = function (this: TravelDoc) { - for (const stage of this.stages) { - if (stage.transport.type === 'ownCar') { - if (stage.transport.distance && stage.transport.distanceRefundType) { - stage.cost = Object.assign(stage.cost, { - amount: Math.round(stage.transport.distance * settings.distanceRefunds[stage.transport.distanceRefundType] * 100) / 100, - currency: baseCurrency - }) - } - } - } -} - travelSchema.methods.addComment = function (this: TravelDoc) { if (this.comment) { this.comments.push({ text: this.comment, author: this.editor, toState: this.state } as TravelComment) @@ -515,72 +255,16 @@ travelSchema.methods.addComment = function (this: TravelDoc) { } } -travelSchema.methods.validateDates = function (this: TravelDoc) { - const conflicts = new Set() - for (var i = 0; i < this.stages.length; i++) { - for (var j = 0; j < this.stages.length; j++) { - if (i !== j) { - if (this.stages[i].departure.valueOf() < this.stages[j].departure.valueOf()) { - if (this.stages[i].arrival.valueOf() <= this.stages[j].departure.valueOf()) { - continue - } else { - if (this.stages[i].arrival.valueOf() <= this.stages[j].arrival.valueOf()) { - // end of [i] inside of [j] - conflicts.add('stages.' + i + '.arrival') - conflicts.add('stages.' + j + '.departure') - } else { - // [j] inside of [i] - conflicts.add('stages.' + j + '.arrival') - conflicts.add('stages.' + j + '.departure') - } - } - } else if (this.stages[i].departure.valueOf() < this.stages[j].arrival.valueOf()) { - if (this.stages[i].arrival.valueOf() <= this.stages[j].arrival.valueOf()) { - // [i] inside of [j] - conflicts.add('stages.' + i + '.arrival') - conflicts.add('stages.' + i + '.departure') - } else { - // end of [j] inside of [i] - conflicts.add('stages.' + j + '.arrival') - conflicts.add('stages.' + i + '.departure') - } - } else { - continue - } - } - } - } - for (const conflict of conflicts) { - this.invalidate(conflict, 'stagesOverlapping') - } -} - -travelSchema.methods.validateCountries = function (this: TravelDoc) { - const conflicts = [] - for (var i = 1; i < this.stages.length; i++) { - if (this.stages[i - 1].endLocation.country._id !== this.stages[i].startLocation.country._id) { - conflicts.push('stages.' + (i - 1) + '.endLocation.country') - conflicts.push('stages.' + i + '.startLocation.country') - } - } - for (const conflict of conflicts) { - this.invalidate(conflict, 'countryChangeBetweenStages') - } -} - travelSchema.pre('validate', async function (this: TravelDoc, next) { this.addComment() - this.validateDates() - this.validateCountries() await populate(this) - this.calculateProgress() - await this.calculateDays() - this.calculateProfessionalShare() - this.calculateRefundforOwnCar() - await this.addCateringRefunds() - await this.addOvernightRefunds() + const conflicts = await travelCalculator.calc(this) + + for (const conflict of conflicts) { + this.invalidate(conflict.path, conflict.err, conflict.val) + } await this.calculateExchangeRates() diff --git a/backend/models/user.ts b/backend/models/user.ts index dd9a264c..3f330604 100644 --- a/backend/models/user.ts +++ b/backend/models/user.ts @@ -9,9 +9,9 @@ import { locales, userReplaceCollections } from '../../common/types.js' -import Settings from './settings.js' +import { getSettings } from '../helper.js' -const settings = (await Settings.findOne().lean())! +const settings = await getSettings() const accessObject: { [key in Access]?: { type: BooleanConstructor; default: boolean; label: string } } = {} for (const access of accesses) { diff --git a/backend/pdf/travel.ts b/backend/pdf/travel.ts index 7a6d1e35..ebb744a5 100644 --- a/backend/pdf/travel.ts +++ b/backend/pdf/travel.ts @@ -19,9 +19,8 @@ import { TravelExpense, baseCurrency } from '../../common/types.js' -import { getDateOfSubmission } from '../helper.js' +import { getDateOfSubmission, getSettings } from '../helper.js' import i18n from '../i18n.js' -import Settings from '../models/settings.js' import { Column, Options, @@ -272,7 +271,7 @@ async function drawStages( receiptMap: ReceiptMap, options: Options ) { - const settings = (await Settings.findOne().lean())! + const settings = await getSettings() if (travel.stages.length == 0) { return options.yStart } @@ -320,7 +319,7 @@ async function drawStages( t.type === 'ownCar' ? i18n.t('distanceRefundTypes.' + t.distanceRefundType, { lng: options.language }) + ' (' + - settings.distanceRefunds[t.distanceRefundType] + + settings.travelSettings.distanceRefunds[t.distanceRefundType] + ' ' + baseCurrency.symbol + '/km)' diff --git a/backend/retentionpolicy.ts b/backend/retentionpolicy.ts index f2818713..658807dd 100644 --- a/backend/retentionpolicy.ts +++ b/backend/retentionpolicy.ts @@ -11,9 +11,9 @@ import { reportIsTravel, schemaNames } from '../common/types.js' +import { getSettings } from './helper.js' import i18n from './i18n.js' import { sendMail } from './mail/mail.js' -import Settings from './models/settings.js' import User from './models/user.js' async function getForRetentionPolicy(schema: schemaNames, date: Date, state: AnyState, startDate?: Date) { @@ -138,16 +138,10 @@ async function sendNotificationMails(report: ITravel | IExpenseReport | IHealthC } } } -async function getSettings() { - return await Settings.findOne({}).lean() -} export async function retentionPolicy() { const settings = await getSettings() - if (settings) { - await notificationMailForDeletions(settings.retentionPolicy) - await triggerDeletion(settings.retentionPolicy) - } else { - console.error('Settings not found!') - } + + await notificationMailForDeletions(settings.retentionPolicy) + await triggerDeletion(settings.retentionPolicy) } diff --git a/backups/backup.sh b/backups/backup.sh index 70ae9fd1..469dafc8 100755 --- a/backups/backup.sh +++ b/backups/backup.sh @@ -1,7 +1,7 @@ # make scripte executable: `chmod +x backups/backup.sh` # Cron Entry “At 00:01 on Sunday.” `crontab -e` -# 1 0 * * 0 ~/projects/meal-week/backups/backup.sh +# 1 0 * * 0 ~/PATH_TO_abrechnung/backups/backup.sh SCRIPT=$(realpath "$0") SCRIPTPATH=$(dirname "$SCRIPT") diff --git a/common/forms/settings.json b/common/forms/settings.json index 68939e37..69f842da 100755 --- a/common/forms/settings.json +++ b/common/forms/settings.json @@ -1 +1 @@ -{"allowSpouseRefund":{"rules":["nullable"],"type":"checkbox","text":{"de":"Abrechnung für Ehepartner bei Reisen erlauben","en":"Allow accounting for spouses when traveling"}},"allowTravelApplicationForThePast":{"rules":["nullable"],"type":"checkbox","text":{"de":"Reiseanträge für die Vergangenheit erlauben","en":"Allow travel applications for the past"}},"vehicleRegistrationWhenUsingOwnCar":{"rules":["required"],"placeholder":{"de":"Fahrzeugschein wenn mit eigenem Auto unterwegs","en":"Vehicle registration if traveling with own car"},"type":"select","items":{"required":{"de":"erforderlich","en":"required"},"optional":{"de":"optional","en":"optional"},"none":{"de":"gar nicht","en":"not at all"}}},"userCanSeeAllProjects":{"rules":["nullable"],"type":"checkbox","text":{"de":"Benutzer können ALLE Projekte sehen","en":"Users can see ALL projects"}},"defaultAccess":{"rules":["required"],"label":{"de":"Standard Zugriffsrechte","en":"Default access rights"},"type":"object","schema":{"user":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Home","en":"Home"}},"inWork:expenseReport":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Auslagenabrechnung initiieren","en":"Initiate Expenses"}},"inWork:healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkostenabrechnung initiieren","en":"Initiate Health Care Costs"}},"appliedFor:travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisekostenabrechnung beantragen","en":"Apply For Travels"}},"approved:travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisekostenabrechnung ohne Genehmigung initiieren","en":"Initiate Travel Expenses Without Approval"}},"approve/travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisen genehmigen","en":"Approve Travels"}},"examine/travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisen prüfen","en":"Examine Travels"}},"examine/expenseReport":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Auslagen prüfen","en":"Examine Expenses"}},"examine/healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkosten prüfen","en":"Examine Health Care Costs"}},"confirm/healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkosten bestätigen","en":"Confirm Health Care Costs"}},"admin":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Administrator","en":"Administrator"}}}},"disableReportType":{"rules":["required"],"label":{"de":"Antragsarten deaktivieren","en":"Deactivate application types"},"type":"object","schema":{"travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reise","en":"Travel"}},"expenseReport":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Auslagenabrechnung","en":"Expense Report"}},"healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkosten","en":"Health care cost"}}}},"maxTravelDayCount":{"rules":["required","min:0","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Maximale Länge eine Dienstreise [Tage]","en":"Maximum length of a business trip [days]"}},"toleranceStageDatesToApprovedTravelDates":{"rules":["required","min:0","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Abweichungstoleranz der Etappen von den beantragten Daten [Tage]","en":"Deviation tolerance of the stages from the requested dates [days]"}},"distanceRefunds":{"rules":["required"],"label":{"de":"Entfernungspauschalen","en":"Distance refunds"},"type":"object","schema":{"car":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Auto 🚗","en":"Car 🚗"}},"motorcycle":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Motorrad 🛵","en":"Motorcycle 🛵"}},"halfCar":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Weg zur Arbeit 🚗","en":"Way to Work 🚗"}}}},"uploadTokenExpireAfterSeconds":{"rules":["required","min:0","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Uploadtoken Ablaufzeit [Sekunden]","en":"Upload token expiration time [seconds]"}},"breakfastCateringLumpSumCut":{"rules":["required","min:0","max:1","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Verpflegungspauschalenkürung fürs Frühstück","en":"Catering lump sum cut for breakfast"}},"lunchCateringLumpSumCut":{"rules":["required","min:0","max:1","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Verpflegungspauschalenkürung fürs Mittagessen","en":"Catering lump sum cut for lunch"}},"dinnerCateringLumpSumCut":{"rules":["required","min:0","max:1","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Verpflegungspauschalenkürung fürs Abendessen","en":"Catering flat rate reduction for dinner"}},"factorCateringLumpSum":{"rules":["required","min:0","max:1","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Faktor für die Verpflegungspauschalen","en":"Factor for the catering lump sums"}},"factorCateringLumpSumExceptions":{"rules":["min:0"],"type":"country","extendOptions":{"mode":"multiple"},"placeholder":{"de":"Ausnahmen der Faktorisierung für die Verpflegungspauschalen","en":"Exceptions to the factorization of the catering lump sums"}},"factorOvernightLumpSum":{"rules":["required","min:0","max:1","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Faktor für die Übernachtungspauschalen","en":"Factor for the overnight lump sums"}},"factorOvernightLumpSumExceptions":{"rules":["min:0"],"type":"country","extendOptions":{"mode":"multiple"},"placeholder":{"de":"Ausnahmen der Faktorisierung für die Übernachtungspauschalen","en":"Exceptions to the factorization of the overnight lump sums"}},"fallBackLumpSumCountry":{"rules":["required"],"type":"country","placeholder":{"de":"Ausweichland für die Pauschalen","en":"Fall back country for lump sums"}},"secoundNightOnAirplaneLumpSumCountry":{"rules":["required"],"type":"country","placeholder":{"de":"Land für die Übernachtungspauschalen ab 2. Nacht im Flugzeug","en":"Country for the overnight lump sums from the 2nd night in an aircraft"}},"secoundNightOnShipOrFerryLumpSumCountry":{"rules":["required"],"type":"country","placeholder":{"de":"Land für die Übernachtungspauschalen ab 2. Nacht auf dem Schiff","en":"Country for the overnight lump sums from the 2nd night on a ship"}},"stateColors":{"rules":["required"],"label":{"de":"Status Farben","en":"State colors"},"type":"object","schema":{"rejected":{"rules":["required"],"label":{"de":"Abgelehnt","en":"Rejected"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"appliedFor":{"rules":["required"],"label":{"de":"Beantragt","en":"Applied for"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"approved":{"rules":["required"],"label":{"de":"Genehmigt","en":"Approved"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"underExamination":{"rules":["required"],"label":{"de":"Wird Geprüft","en":"Under examination"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"refunded":{"rules":["required"],"label":{"de":"Erstattet","en":"Refunded"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"inWork":{"rules":["required"],"label":{"de":"in Arbeit","en":"In work"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"underExaminationByInsurance":{"rules":["required"],"label":{"de":"Prüfung durch Krankenkasse","en":"Under examination by insurance"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}}}},"accessIcons":{"rules":["required"],"label":{"de":"Icons für Zugriffsrechte","en":"Icons for access rights"},"type":"object","schema":{"user":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Home","en":"Home"},"type":"list","element":{"rules":["required"],"type":"text"}},"inWork:expenseReport":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Auslagenabrechnung initiieren","en":"Initiate Expenses"},"type":"list","element":{"rules":["required"],"type":"text"}},"inWork:healthCareCost":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Krankenkostenabrechnung initiieren","en":"Initiate Health Care Costs"},"type":"list","element":{"rules":["required"],"type":"text"}},"appliedFor:travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisekostenabrechnung beantragen","en":"Apply For Travels"},"type":"list","element":{"rules":["required"],"type":"text"}},"approved:travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisekostenabrechnung ohne Genehmigung initiieren","en":"Initiate Travel Expenses Without Approval"},"type":"list","element":{"rules":["required"],"type":"text"}},"approve/travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisen genehmigen","en":"Approve Travels"},"type":"list","element":{"rules":["required"],"type":"text"}},"examine/travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisen prüfen","en":"Examine Travels"},"type":"list","element":{"rules":["required"],"type":"text"}},"examine/expenseReport":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Auslagen prüfen","en":"Examine Expenses"},"type":"list","element":{"rules":["required"],"type":"text"}},"examine/healthCareCost":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Krankenkosten prüfen","en":"Examine Health Care Costs"},"type":"list","element":{"rules":["required"],"type":"text"}},"confirm/healthCareCost":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Krankenkosten bestätigen","en":"Confirm Health Care Costs"},"type":"list","element":{"rules":["required"],"type":"text"}},"admin":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Administrator","en":"Administrator"},"type":"list","element":{"rules":["required"],"type":"text"}}}},"retentionPolicy":{"rules":["required"],"label":{"de":"Aufbewahrungsrichtlinien","en":"Retention policy"},"type":"object","schema":{"deleteRefundedAfterXDays":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine automatische Löschung durchzuführen 0 eingeben","en":"Enter 0 to prevent automatic deletion"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Löschen von erstatteten Anträge und Reisen - in Tagen","en":"Delete refunded reports and trips - in days"}},"deleteApprovedTravelAfterXDaysUnused":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine automatische Löschung durchzuführen 0 eingeben","en":"Enter 0 to prevent automatic deletion"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Löschen von genehmigten, ungenutzten Reisen - in Tagen","en":"Delete approved, unused trips - in days"}},"deleteInWorkReportsAfterXDaysUnused":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine automatische Löschung durchzuführen 0 eingeben","en":"Enter 0 to prevent automatic deletion"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Löschen von ungenutzen Auslagen- und Krankenkostenanträgen - in Tagen","en":"Delete unused expense and healthcare cost reports - in days"}},"mailXDaysBeforeDeletion":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine Hinweismail zuversenden 0 eingeben","en":"Enter 0 to prevent reminder email"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Informationsmail vor dem Löschen - in Tagen","en":"Receive notification mail before deletion - in days"}}}}} \ No newline at end of file +{"userCanSeeAllProjects":{"rules":["nullable"],"type":"checkbox","text":{"de":"Benutzer können ALLE Projekte sehen","en":"Users can see ALL projects"}},"defaultAccess":{"rules":["required"],"label":{"de":"Standard Zugriffsrechte","en":"Default access rights"},"type":"object","schema":{"user":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Home","en":"Home"}},"inWork:expenseReport":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Auslagenabrechnung initiieren","en":"Initiate Expenses"}},"inWork:healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkostenabrechnung initiieren","en":"Initiate Health Care Costs"}},"appliedFor:travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisekostenabrechnung beantragen","en":"Apply For Travels"}},"approved:travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisekostenabrechnung ohne Genehmigung initiieren","en":"Initiate Travel Expenses Without Approval"}},"approve/travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisen genehmigen","en":"Approve Travels"}},"examine/travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reisen prüfen","en":"Examine Travels"}},"examine/expenseReport":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Auslagen prüfen","en":"Examine Expenses"}},"examine/healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkosten prüfen","en":"Examine Health Care Costs"}},"confirm/healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkosten bestätigen","en":"Confirm Health Care Costs"}},"admin":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Administrator","en":"Administrator"}}}},"disableReportType":{"rules":["required"],"label":{"de":"Antragsarten deaktivieren","en":"Deactivate application types"},"type":"object","schema":{"travel":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Reise","en":"Travel"}},"expenseReport":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Auslagenabrechnung","en":"Expense Report"}},"healthCareCost":{"rules":["nullable"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"checkbox","text":{"de":"Krankenkosten","en":"Health care cost"}}}},"retentionPolicy":{"rules":["required"],"label":{"de":"Aufbewahrungsrichtlinien","en":"Retention policy"},"type":"object","schema":{"deleteRefundedAfterXDays":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine automatische Löschung durchzuführen 0 eingeben","en":"Enter 0 to prevent automatic deletion"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Löschen von erstatteten Anträge und Reisen - in Tagen","en":"Delete refunded reports and trips - in days"}},"deleteApprovedTravelAfterXDaysUnused":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine automatische Löschung durchzuführen 0 eingeben","en":"Enter 0 to prevent automatic deletion"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Löschen von genehmigten, ungenutzten Reisen - in Tagen","en":"Delete approved, unused trips - in days"}},"deleteInWorkReportsAfterXDaysUnused":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine automatische Löschung durchzuführen 0 eingeben","en":"Enter 0 to prevent automatic deletion"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Löschen von ungenutzen Auslagen- und Krankenkostenanträgen - in Tagen","en":"Delete unused expense and healthcare cost reports - in days"}},"mailXDaysBeforeDeletion":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"description":{"de":"Um keine Hinweismail zuversenden 0 eingeben","en":"Enter 0 to prevent reminder email"},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Informationsmail vor dem Löschen - in Tagen","en":"Receive notification mail before deletion - in days"}}}},"travelSettings":{"rules":["required"],"label":{"de":"Reisekostenabrechnungseinstellungen","en":"Travel Settings"},"type":"object","schema":{"maxTravelDayCount":{"rules":["required","min:0","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Maximale Länge eine Dienstreise [Tage]","en":"Maximum length of a business trip [days]"}},"allowSpouseRefund":{"rules":["nullable"],"type":"checkbox","text":{"de":"Abrechnung für Ehepartner bei Reisen erlauben","en":"Allow accounting for spouses when traveling"}},"allowTravelApplicationForThePast":{"rules":["nullable"],"type":"checkbox","text":{"de":"Reiseanträge für die Vergangenheit erlauben","en":"Allow travel applications for the past"}},"toleranceStageDatesToApprovedTravelDates":{"rules":["required","min:0","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Abweichungstoleranz der Etappen von den beantragten Daten [Tage]","en":"Deviation tolerance of the stages from the requested dates [days]"}},"distanceRefunds":{"rules":["required"],"label":{"de":"Entfernungspauschalen","en":"Distance refunds"},"type":"object","schema":{"car":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Auto 🚗","en":"Car 🚗"}},"motorcycle":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Motorrad 🛵","en":"Motorcycle 🛵"}},"halfCar":{"rules":["required","min:0","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Weg zur Arbeit 🚗","en":"Way to Work 🚗"}}}},"vehicleRegistrationWhenUsingOwnCar":{"rules":["required"],"placeholder":{"de":"Fahrzeugschein wenn mit eigenem Auto unterwegs","en":"Vehicle registration if traveling with own car"},"type":"select","items":{"required":{"de":"erforderlich","en":"required"},"optional":{"de":"optional","en":"optional"},"none":{"de":"gar nicht","en":"not at all"}}},"lumpSumCut":{"rules":["required"],"label":{"de":"labels.lumpSumCut","en":"labels.lumpSumCut"},"type":"object","schema":{"breakfast":{"rules":["required","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Frühstück","en":"Breakfast"}},"lunch":{"rules":["required","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Mittagessen","en":"Lunch"}},"dinner":{"rules":["required","numeric"],"columns":{"lg":{"container":4},"sm":{"container":6}},"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Abendessen","en":"Dinner"}}}},"factorCateringLumpSum":{"rules":["required","min:0","max:1","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Faktor für die Verpflegungspauschalen","en":"Factor for the catering lump sums"}},"factorCateringLumpSumExceptions":{"rules":["min:0"],"type":"country","extendOptions":{"mode":"multiple"},"placeholder":{"de":"Ausnahmen der Faktorisierung für die Verpflegungspauschalen","en":"Exceptions to the factorization of the catering lump sums"}},"factorOvernightLumpSum":{"rules":["required","min:0","max:1","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Faktor für die Übernachtungspauschalen","en":"Factor for the overnight lump sums"}},"factorOvernightLumpSumExceptions":{"rules":["min:0"],"type":"country","extendOptions":{"mode":"multiple"},"placeholder":{"de":"Ausnahmen der Faktorisierung für die Übernachtungspauschalen","en":"Exceptions to the factorization of the overnight lump sums"}},"fallBackLumpSumCountry":{"rules":["required"],"type":"country","placeholder":{"de":"Ausweichland für die Pauschalen","en":"Fall back country for lump sums"}},"secoundNightOnAirplaneLumpSumCountry":{"rules":["required"],"type":"country","placeholder":{"de":"Land für die Übernachtungspauschalen ab 2. Nacht im Flugzeug","en":"Country for the overnight lump sums from the 2nd night in an aircraft"}},"secoundNightOnShipOrFerryLumpSumCountry":{"rules":["required"],"type":"country","placeholder":{"de":"Land für die Übernachtungspauschalen ab 2. Nacht auf dem Schiff","en":"Country for the overnight lump sums from the 2nd night on a ship"}}}},"uploadTokenExpireAfterSeconds":{"rules":["required","min:0","numeric"],"type":"text","inputType":"number","attrs":{"step":"any"},"forceNumbers":true,"placeholder":{"de":"Uploadtoken Ablaufzeit [Sekunden]","en":"Upload token expiration time [seconds]"}},"stateColors":{"rules":["required"],"label":{"de":"Status Farben","en":"State colors"},"type":"object","schema":{"rejected":{"rules":["required"],"label":{"de":"Abgelehnt","en":"Rejected"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"appliedFor":{"rules":["required"],"label":{"de":"Beantragt","en":"Applied for"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"approved":{"rules":["required"],"label":{"de":"Genehmigt","en":"Approved"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"underExamination":{"rules":["required"],"label":{"de":"Wird Geprüft","en":"Under examination"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"refunded":{"rules":["required"],"label":{"de":"Erstattet","en":"Refunded"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"inWork":{"rules":["required"],"label":{"de":"in Arbeit","en":"In work"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}},"underExaminationByInsurance":{"rules":["required"],"label":{"de":"Prüfung durch Krankenkasse","en":"Under examination by insurance"},"type":"object","schema":{"color":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Farbe","en":"Color"},"type":"text"},"text":{"rules":["required"],"columns":{"lg":{"container":6},"sm":{"container":6}},"placeholder":{"de":"Text","en":"Text"},"type":"text"}}}}},"accessIcons":{"rules":["required"],"label":{"de":"Icons für Zugriffsrechte","en":"Icons for access rights"},"type":"object","schema":{"user":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Home","en":"Home"},"type":"list","element":{"rules":["required"],"type":"text"}},"inWork:expenseReport":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Auslagenabrechnung initiieren","en":"Initiate Expenses"},"type":"list","element":{"rules":["required"],"type":"text"}},"inWork:healthCareCost":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Krankenkostenabrechnung initiieren","en":"Initiate Health Care Costs"},"type":"list","element":{"rules":["required"],"type":"text"}},"appliedFor:travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisekostenabrechnung beantragen","en":"Apply For Travels"},"type":"list","element":{"rules":["required"],"type":"text"}},"approved:travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisekostenabrechnung ohne Genehmigung initiieren","en":"Initiate Travel Expenses Without Approval"},"type":"list","element":{"rules":["required"],"type":"text"}},"approve/travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisen genehmigen","en":"Approve Travels"},"type":"list","element":{"rules":["required"],"type":"text"}},"examine/travel":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Reisen prüfen","en":"Examine Travels"},"type":"list","element":{"rules":["required"],"type":"text"}},"examine/expenseReport":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Auslagen prüfen","en":"Examine Expenses"},"type":"list","element":{"rules":["required"],"type":"text"}},"examine/healthCareCost":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Krankenkosten prüfen","en":"Examine Health Care Costs"},"type":"list","element":{"rules":["required"],"type":"text"}},"confirm/healthCareCost":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Krankenkosten bestätigen","en":"Confirm Health Care Costs"},"type":"list","element":{"rules":["required"],"type":"text"}},"admin":{"rules":["min:0"],"columns":{"lg":{"container":4},"sm":{"container":6}},"label":{"de":"Administrator","en":"Administrator"},"type":"list","element":{"rules":["required"],"type":"text"}}}}} \ No newline at end of file diff --git a/common/locales/de.json b/common/locales/de.json index 31c0dd8b..cc383286 100755 --- a/common/locales/de.json +++ b/common/locales/de.json @@ -99,10 +99,6 @@ "vehicleRegistration": "Lade hier den Fahrzeugschein deines Autos hoch." }, "labels": { - "appliedForOn": "Beantragt am", - "approvedOn": "Genehmigt am", - "submittedOn": "Eingereicht am", - "examinedOn": "Geprüft am", "access": "Zugriffsrechte", "accessIcons": "Icons für Zugriffsrechte", "add": "Hinzufügen", @@ -114,8 +110,10 @@ "allowTravelApplicationForThePast": "Reiseanträge für die Vergangenheit erlauben", "amount": "Betrag", "applicant": "Antragsteller", + "appliedForOn": "Beantragt am", "applyForX": "{X} beantragen", "approve": "Genehmigen", + "approvedOn": "Genehmigt am", "approvedTravels": "Genehmigte Reisen", "arrival": "Ankunft", "backToApplicant": "Zurück zum Antragsteller", @@ -151,9 +149,9 @@ "de": "Deutsch 🇩🇪", "defaultAccess": "Standard Zugriffsrechte", "delete": "Löschen", - "deleteRefundedAfterXDays": "Löschen von erstatteten Anträge und Reisen - in Tagen", "deleteApprovedTravelAfterXDaysUnused": "Löschen von genehmigten, ungenutzten Reisen - in Tagen", "deleteInWorkReportsAfterXDaysUnused": "Löschen von ungenutzen Auslagen- und Krankenkostenanträgen - in Tagen", + "deleteRefundedAfterXDays": "Löschen von erstatteten Anträge und Reisen - in Tagen", "deleteX": "{X} löschen", "delOverwritten": "Soll der zu überschreibende Benutzer gelöscht werden?", "departure": "Abfahrt /-flug", @@ -177,6 +175,7 @@ "endLocation": "Nach", "error": "Fehler ❌", "examine": "Prüfen", + "examinedOn": "Geprüft am", "expandAll": "Alle ausklappen", "expense": "Auslage", "expensePayer": "Auslagenzahler", @@ -286,6 +285,7 @@ "stateColors": "Status Farben", "stay": "Aufenthalt", "subfolderPath": "Unterordner Pfad", + "submittedOn": "Eingereicht am", "submitX": "{X} einreichen", "subunit": "Teileinheit", "success": "Erfolgreich ✅", @@ -305,6 +305,7 @@ "traveler": "Reisender", "travelInsideOfEU": "Grenzüberschreitende Reise innerhalb der EU", "travelName": "Name der Reise", + "travelSettings": "Reisekostenabrechnungseinstellungen", "typeToSearch": "Tippe um zu Suchen...", "underExaminationByInsuranceHealthCareCosts": "Krankenkosten in Prüfung bei Krankenkassen", "uploadFromPhone": "Vom Handy hochladen", diff --git a/common/locales/en.json b/common/locales/en.json index 9aed19b1..5d8a64b6 100755 --- a/common/locales/en.json +++ b/common/locales/en.json @@ -99,10 +99,6 @@ "vehicleRegistration": "Upload the vehicle registration of your car here." }, "labels": { - "appliedForOn": "Applied for on", - "approvedOn": "Approved on", - "submittedOn": "Submitted on", - "examinedOn": "Examined on", "access": "Access", "accessIcons": "Icons for access rights", "add": "Add", @@ -114,8 +110,10 @@ "allowTravelApplicationForThePast": "Allow travel applications for the past", "amount": "Amount", "applicant": "Applicant", + "appliedForOn": "Applied for on", "applyForX": "Apply for {X}", "approve": "Approve", + "approvedOn": "Approved on", "approvedTravels": "approved travels", "arrival": "Arrival", "backToApplicant": "Back to applicant", @@ -151,9 +149,9 @@ "de": "German 🇩🇪", "defaultAccess": "Default access rights", "delete": "Delete", - "deleteRefundedAfterXDays": "Delete refunded reports and trips - in days", "deleteApprovedTravelAfterXDaysUnused": "Delete approved, unused trips - in days", "deleteInWorkReportsAfterXDaysUnused": "Delete unused expense and healthcare cost reports - in days", + "deleteRefundedAfterXDays": "Delete refunded reports and trips - in days", "deleteX": "Delete {X}", "delOverwritten": "Should the overwritten user be deleted?", "departure": "Departure", @@ -177,6 +175,7 @@ "endLocation": "End", "error": "Error ❌", "examine": "Examine", + "examinedOn": "Examined on", "expandAll": "Expand all", "expense": "Expense", "expensePayer": "Expense Payer", @@ -286,6 +285,7 @@ "stateColors": "State colors", "stay": "Stay", "subfolderPath": "Subfolder path", + "submittedOn": "Submitted on", "submitX": "Submit {X}", "subunit": "Subunit", "success": "Sucess ✅", @@ -305,6 +305,7 @@ "traveler": "Traveler", "travelInsideOfEU": "Travel inside of the EU", "travelName": "Travel name", + "travelSettings": "Travel Settings", "typeToSearch": "Type to search...", "underExaminationByInsuranceHealthCareCosts": "Health care costs under examination by health insurances", "uploadFromPhone": "Upload from Phone", diff --git a/common/travel.ts b/common/travel.ts new file mode 100644 index 00000000..c0d30044 --- /dev/null +++ b/common/travel.ts @@ -0,0 +1,384 @@ +import { datetimeToDate, getDayList, getDiffInDays } from './scripts.js' +import { + baseCurrency, + Country, + CountryCode, + CountrySimple, + LumpSum, + Meal, + Place, + PurposeSimple, + Refund, + SettingsTravel, + Stage, + Travel, + TravelDayFullCountry, + TravelExpense +} from './types.js' + +export class TravelCalculator { + getCountryById: (id: CountryCode) => Promise + lumpSumCalculator!: LumpSumCalculator + validator: TravelValidator + travelSettings!: SettingsTravel + stagesCompareFn = (a: Stage, b: Stage) => new Date(a.departure).valueOf() - new Date(b.departure).valueOf() + expensesCompareFn = (a: TravelExpense, b: TravelExpense) => new Date(a.cost.date).valueOf() - new Date(b.cost.date).valueOf() + + constructor(getCountryById: (id: CountryCode) => Promise, travelSettings: SettingsTravel) { + this.getCountryById = getCountryById + this.validator = new TravelValidator() + this.updateSettings(travelSettings) + } + + async calc(travel: Travel): Promise { + this.sort(travel) + const conflicts = this.validator.validate(travel) + if (conflicts.length === 0) { + this.calculateProgress(travel) + await this.calculateDays(travel) + this.calculateProfessionalShare(travel) + this.calculateRefundforOwnCar(travel) + await this.addCateringRefunds(travel) + await this.addOvernightRefunds(travel) + } + return conflicts + } + + updateSettings(travelSettings: SettingsTravel) { + this.travelSettings = travelSettings + this.lumpSumCalculator = new LumpSumCalculator(this.getCountryById, travelSettings.fallBackLumpSumCountry) + } + + sort(travel: Travel) { + travel.stages.sort(this.stagesCompareFn) + travel.expenses.sort(this.expensesCompareFn) + } + + calculateProgress(travel: Travel) { + if (travel.stages.length > 0) { + var approvedLength = getDiffInDays(travel.startDate, travel.endDate) + 1 + var stageLength = getDiffInDays(travel.stages[0].departure, travel.stages[travel.stages.length - 1].arrival) + 1 + if (stageLength >= approvedLength) { + travel.progress = 100 + } else { + travel.progress = Math.round((stageLength / approvedLength) * 100) + } + } else { + travel.progress = 0 + } + } + + getDays(travel: Travel) { + if (travel.stages.length > 0) { + const days = getDayList(travel.stages[0].departure, travel.stages[travel.stages.length - 1].arrival) + const newDays: { date: Date; cateringNoRefund?: { [key in Meal]: boolean }; purpose?: PurposeSimple; refunds: Refund[] }[] = days.map( + (d) => { + return { date: d, refunds: [] } + } + ) + for (const oldDay of travel.days) { + for (const newDay of newDays) { + if (new Date(oldDay.date).valueOf() - new Date(newDay.date!).valueOf() == 0) { + newDay.cateringNoRefund = oldDay.cateringNoRefund + newDay.purpose = oldDay.purpose + break + } + } + } + return newDays + } else { + return [] + } + } + + getBorderCrossings(travel: Travel) { + const borderCrossings: { date: Date; country: { _id: CountryCode }; special?: string }[] = [] + if (travel.stages.length > 0) { + const startCountry = travel.stages[0].startLocation.country + borderCrossings.push({ date: new Date(travel.stages[0].departure), country: startCountry }) + + for (var i = 0; i < travel.stages.length; i++) { + const stage = travel.stages[i] + // Country Change (or special change) + if ( + stage.startLocation && + stage.endLocation && + (stage.startLocation.country._id !== stage.endLocation.country._id || stage.startLocation.special !== stage.endLocation.special) + ) { + // More than 1 night + if (getDiffInDays(stage.departure, stage.arrival) > 1) { + if (['ownCar', 'otherTransport'].indexOf(stage.transport.type) !== -1) { + if (stage.midnightCountries) borderCrossings.push(...(stage.midnightCountries as { date: Date; country: CountrySimple }[])) + } else if (stage.transport.type === 'airplane') { + borderCrossings.push({ + date: new Date(new Date(stage.departure).valueOf() + 24 * 60 * 60 * 1000), + country: { _id: this.travelSettings.secoundNightOnAirplaneLumpSumCountry } + }) + } else if (stage.transport.type === 'shipOrFerry') { + borderCrossings.push({ + date: new Date(new Date(stage.departure).valueOf() + 24 * 60 * 60 * 1000), + country: { _id: this.travelSettings.secoundNightOnShipOrFerryLumpSumCountry } + }) + } + } + borderCrossings.push({ date: new Date(stage.arrival), country: stage.endLocation.country, special: stage.endLocation.special }) + } + } + } + return borderCrossings + } + + getDateOfLastPlaceOfWork(travel: Travel) { + var date: Date | null = null + function sameCountryAndSpecial(placeA: Place, placeB: Place): boolean { + return placeA.country._id === placeB.country._id && placeA.special === placeB.special + } + for (var i = travel.stages.length - 1; i >= 0; i--) { + if (sameCountryAndSpecial(travel.stages[i].endLocation, travel.lastPlaceOfWork)) { + date = datetimeToDate(travel.stages[i].arrival) + break + } else if (sameCountryAndSpecial(travel.stages[i].startLocation, travel.lastPlaceOfWork)) { + date = datetimeToDate(travel.stages[i].departure) + break + } + } + return date + } + + async calculateDays(travel: Travel) { + const borderCrossings: { date: Date; country: Country; special?: string }[] = [] + + for (const borderX of this.getBorderCrossings(travel)) { + borderCrossings.push({ date: borderX.date, country: await this.getCountryById(borderX.country._id), special: borderX.special }) + } + const days = this.getDays(travel) + var bXIndex = 0 + for (const day of days) { + while ( + bXIndex < borderCrossings.length - 1 && + day.date.valueOf() + 1000 * 24 * 60 * 60 - 1 - borderCrossings[bXIndex + 1].date.valueOf() > 0 + ) { + bXIndex++ + } + ;(day as Partial).country = borderCrossings[bXIndex].country + ;(day as Partial).special = borderCrossings[bXIndex].special + } + + // change days according to last place of work + const dateOfLastPlaceOfWork = this.getDateOfLastPlaceOfWork(travel) + + if (dateOfLastPlaceOfWork) { + for (const day of days) { + if (day.date.valueOf() >= dateOfLastPlaceOfWork.valueOf()) { + ;(day as Partial).country = await this.getCountryById(travel.lastPlaceOfWork.country._id) + ;(day as Partial).special = travel.lastPlaceOfWork.special + } + } + } + + travel.days = days as TravelDayFullCountry[] + } + + async addCateringRefunds(travel: Travel) { + for (var i = 0; i < travel.days.length; i++) { + const day = travel.days[i] as TravelDayFullCountry + if (day.purpose == 'professional') { + const result: Partial = { type: 'catering24' } + if (i == 0 || i == travel.days.length - 1) { + result.type = 'catering8' + } + var amount = (await this.lumpSumCalculator.getLumpSum(day.country, day.date as Date, day.special))[result.type!] + var leftover = 1 + if (day.cateringNoRefund.breakfast) leftover -= this.travelSettings.lumpSumCut.breakfast + if (day.cateringNoRefund.lunch) leftover -= this.travelSettings.lumpSumCut.lunch + if (day.cateringNoRefund.dinner) leftover -= this.travelSettings.lumpSumCut.dinner + + result.refund = { + amount: + Math.round( + amount * + leftover * + ((this.travelSettings.factorCateringLumpSumExceptions as string[]).indexOf(day.country._id) == -1 + ? this.travelSettings.factorCateringLumpSum + : 1) * + 100 + ) / 100 + } + if (this.travelSettings.allowSpouseRefund && travel.claimSpouseRefund) { + result.refund.amount! *= 2 + } + day.refunds.push(result as Refund) + } + } + } + + async addOvernightRefunds(travel: Travel) { + if (travel.claimOvernightLumpSum) { + var stageIndex = 0 + for (var i = 0; i < travel.days.length; i++) { + const day = travel.days[i] as TravelDayFullCountry + if (day.purpose == 'professional') { + if (i == travel.days.length - 1) { + break + } + var midnight = (day.date as Date).valueOf() + 1000 * 24 * 60 * 60 - 1 + while (stageIndex < travel.stages.length - 1 && midnight - new Date(travel.stages[stageIndex].arrival).valueOf() > 0) { + stageIndex++ + } + if ( + midnight - new Date(travel.stages[stageIndex].departure).valueOf() > 0 && + new Date(travel.stages[stageIndex].arrival).valueOf() - midnight > 0 + ) { + continue + } + const result: Partial = { type: 'overnight' } + var amount = (await this.lumpSumCalculator.getLumpSum(day.country, day.date as Date, day.special))[result.type!] + result.refund = { + amount: + Math.round( + amount * + (this.travelSettings.factorOvernightLumpSumExceptions.indexOf(day.country._id) == -1 + ? this.travelSettings.factorOvernightLumpSum + : 1) * + 100 + ) / 100 + } + if (this.travelSettings.allowSpouseRefund && travel.claimSpouseRefund) { + result.refund.amount! *= 2 + } + day.refunds.push(result as Refund) + } + } + } + } + + calculateProfessionalShare(travel: Travel) { + if (travel.days.length > 0) { + var professionalDays = 0 + var calc = false + for (const day of travel.days) { + if (day.purpose === 'professional') { + professionalDays += 1 + } else { + calc = true + } + } + if (calc) { + travel.professionalShare = professionalDays / travel.days.length + } else { + travel.professionalShare = 1 + } + } else { + travel.professionalShare = null + } + } + + calculateRefundforOwnCar(travel: Travel) { + for (const stage of travel.stages) { + if (stage.transport.type === 'ownCar') { + if (stage.transport.distance && stage.transport.distanceRefundType) { + stage.cost = Object.assign(stage.cost, { + amount: + Math.round(stage.transport.distance * this.travelSettings.distanceRefunds[stage.transport.distanceRefundType] * 100) / 100, + currency: baseCurrency + }) + } + } + } + } +} + +type Invalid = { path: string; err: string | Error; val?: any } + +export class TravelValidator { + validate(travel: Travel): Invalid[] { + return this.validateDates(travel).concat(this.validateCountries(travel)) + } + + validateDates(travel: Travel): Invalid[] { + const conflicts = new Set() + for (var i = 0; i < travel.stages.length; i++) { + for (var j = 0; j < travel.stages.length; j++) { + if (i !== j) { + if (travel.stages[i].departure.valueOf() < travel.stages[j].departure.valueOf()) { + if (travel.stages[i].arrival.valueOf() <= travel.stages[j].departure.valueOf()) { + continue + } else { + if (travel.stages[i].arrival.valueOf() <= travel.stages[j].arrival.valueOf()) { + // end of [i] inside of [j] + conflicts.add({ path: 'stages.' + i + '.arrival', err: 'stagesOverlapping' }) + conflicts.add({ path: 'stages.' + j + '.departure', err: 'stagesOverlapping' }) + } else { + // [j] inside of [i] + conflicts.add({ path: 'stages.' + j + '.arrival', err: 'stagesOverlapping' }) + conflicts.add({ path: 'stages.' + j + '.departure', err: 'stagesOverlapping' }) + } + } + } else if (travel.stages[i].departure.valueOf() < travel.stages[j].arrival.valueOf()) { + if (travel.stages[i].arrival.valueOf() <= travel.stages[j].arrival.valueOf()) { + // [i] inside of [j] + conflicts.add({ path: 'stages.' + i + '.arrival', err: 'stagesOverlapping' }) + conflicts.add({ path: 'stages.' + i + '.departure', err: 'stagesOverlapping' }) + } else { + // end of [j] inside of [i] + conflicts.add({ path: 'stages.' + j + '.arrival', err: 'stagesOverlapping' }) + conflicts.add({ path: 'stages.' + i + '.departure', err: 'stagesOverlapping' }) + } + } else { + continue + } + } + } + } + return Array.from(conflicts) + } + + validateCountries(travel: Travel): Invalid[] { + const conflicts: Invalid[] = [] + for (var i = 1; i < travel.stages.length; i++) { + if (travel.stages[i - 1].endLocation.country._id !== travel.stages[i].startLocation.country._id) { + conflicts.push({ path: 'stages.' + (i - 1) + '.endLocation.country', err: 'countryChangeBetweenStages' }) + conflicts.push({ path: 'stages.' + i + '.startLocation.country', err: 'countryChangeBetweenStages' }) + } + } + return conflicts + } +} + +export default class LumpSumCalculator { + fallBackLumpSumCountry: CountryCode + getCountryById: (id: CountryCode) => Promise + + constructor(getCountryById: (id: CountryCode) => Promise, fallBackLumpSumCountry: CountryCode) { + this.getCountryById = getCountryById + this.fallBackLumpSumCountry = fallBackLumpSumCountry + } + async getLumpSum(country: Country, date: Date, special: string | undefined = undefined): Promise { + if (country.lumpSumsFrom) { + const lumpSumFrom = await this.getCountryById(country.lumpSumsFrom) + return this.getLumpSum(lumpSumFrom, date) + } else if (country.lumpSums.length == 0) { + const fallBackLumpSumCountry = await this.getCountryById(this.fallBackLumpSumCountry) + return this.getLumpSum(fallBackLumpSumCountry, date) + } else { + var nearest = 0 + for (var i = 0; i < country.lumpSums.length; i++) { + var diff = date.valueOf() - (country.lumpSums[i].validFrom as Date).valueOf() + if (diff >= 0 && diff < date.valueOf() - (country.lumpSums[nearest].validFrom as Date).valueOf()) { + nearest = i + } + } + if (date.valueOf() - (country.lumpSums[nearest].validFrom as Date).valueOf() < 0) { + throw new Error('No valid lumpSum found for Country: ' + country._id + ' for date: ' + date) + } + if (special && country.lumpSums[nearest].specials) { + for (const lumpSumSpecial of country.lumpSums[nearest].specials!) { + if (lumpSumSpecial.city === special) { + return lumpSumSpecial + } + } + } + return country.lumpSums[nearest] + } + } +} diff --git a/common/types.ts b/common/types.ts index 7638556c..62fe8d91 100644 --- a/common/types.ts +++ b/common/types.ts @@ -6,33 +6,17 @@ import { Types } from 'mongoose' export type _id = Types.ObjectId export interface Settings { - accessIcons: { [key in Access]: string[] } - defaultAccess: { [key in Access]: boolean } - allowSpouseRefund: boolean userCanSeeAllProjects: boolean - breakfastCateringLumpSumCut: number - lunchCateringLumpSumCut: number - dinnerCateringLumpSumCut: number - factorCateringLumpSum: number - factorCateringLumpSumExceptions: CountryCode[] - factorOvernightLumpSum: number - factorOvernightLumpSumExceptions: CountryCode[] - fallBackLumpSumCountry: CountryCode - maxTravelDayCount: number - distanceRefunds: { [key in DistanceRefundType]: number } - secoundNightOnAirplaneLumpSumCountry: CountryCode - secoundNightOnShipOrFerryLumpSumCountry: CountryCode - stateColors: { [key in AnyState]: { color: string; text: string } } - toleranceStageDatesToApprovedTravelDates: number - uploadTokenExpireAfterSeconds: number - allowTravelApplicationForThePast: boolean - vehicleRegistrationWhenUsingOwnCar: 'required' | 'optional' | 'none' + defaultAccess: { [key in Access]: boolean } disableReportType: { [key in ReportType]: boolean } - version: string retentionPolicy: { [key in RetentionType]: number } - + travelSettings: SettingsTravel + uploadTokenExpireAfterSeconds: number + stateColors: { [key in AnyState]: { color: string; text: string } } + accessIcons: { [key in Access]: string[] } + version: string /** * @Hidden */ @@ -40,6 +24,23 @@ export interface Settings { _id: _id } +export interface SettingsTravel { + maxTravelDayCount: number + allowSpouseRefund: boolean + allowTravelApplicationForThePast: boolean + toleranceStageDatesToApprovedTravelDates: number + distanceRefunds: { [key in DistanceRefundType]: number } + vehicleRegistrationWhenUsingOwnCar: 'required' | 'optional' | 'none' + lumpSumCut: { [key in Meal]: number } + factorCateringLumpSum: number + factorCateringLumpSumExceptions: CountryCode[] + factorOvernightLumpSum: number + factorOvernightLumpSumExceptions: CountryCode[] + fallBackLumpSumCountry: CountryCode + secoundNightOnAirplaneLumpSumCountry: CountryCode + secoundNightOnShipOrFerryLumpSumCountry: CountryCode +} + /** * @pattern ^[A-Z]{2}$ */ @@ -273,6 +274,10 @@ export interface Refund { _id: Types.ObjectId } +export interface TravelDayFullCountry extends Omit { + country: Country +} + export interface TravelDay { date: Date | string country: CountrySimple diff --git a/frontend/src/components/travel/forms/StageForm.vue b/frontend/src/components/travel/forms/StageForm.vue index 9e571c25..e41ad2bc 100644 --- a/frontend/src/components/travel/forms/StageForm.vue +++ b/frontend/src/components/travel/forms/StageForm.vue @@ -94,7 +94,7 @@ {{ $t('distanceRefundTypes.' + distanceRefundType) + ' (' + - $root.settings.distanceRefunds[distanceRefundType] + + $root.settings.travelSettings.distanceRefunds[distanceRefundType] + ' ' + baseCurrency.symbol + '/km)' @@ -116,10 +116,10 @@ :disabled="disabled" required /> -
+
@@ -354,12 +354,13 @@ export default defineComponent({ this.loading = false this.minDate = new Date( - new Date(this.travelStartDate).valueOf() - this.$root.settings.toleranceStageDatesToApprovedTravelDates * 24 * 60 * 60 * 1000 + new Date(this.travelStartDate).valueOf() - + this.$root.settings.travelSettings.toleranceStageDatesToApprovedTravelDates * 24 * 60 * 60 * 1000 ) this.maxDate = new Date( new Date(this.travelEndDate).valueOf() + - (this.$root.settings.toleranceStageDatesToApprovedTravelDates + 1) * 24 * 60 * 60 * 1000 - + (this.$root.settings.travelSettings.toleranceStageDatesToApprovedTravelDates + 1) * 24 * 60 * 60 * 1000 - 1 ) diff --git a/frontend/src/components/travel/forms/TravelApplyForm.vue b/frontend/src/components/travel/forms/TravelApplyForm.vue index f6949edc..e6de0e74 100644 --- a/frontend/src/components/travel/forms/TravelApplyForm.vue +++ b/frontend/src/components/travel/forms/TravelApplyForm.vue @@ -39,7 +39,7 @@
@@ -48,7 +48,7 @@
-