1
0
Fork 0
forked from Kispi/Core

feat(webapp): allow wage to be set freely
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Simon Giesel 2023-07-17 19:40:07 +02:00
parent dc3c065d1a
commit 0b087a38bf
13 changed files with 304 additions and 257 deletions

View file

@ -54,119 +54,6 @@
"deleteRule": null,
"options": {}
},
{
"id": "74ftooxenpeq14b",
"name": "accounts",
"type": "base",
"system": false,
"schema": [
{
"id": "as1gvc1r",
"name": "accountNumber",
"type": "text",
"system": false,
"required": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "vxeq20lk",
"name": "firstName",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "bsinpdk7",
"name": "lastName",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "4lw6gmtc",
"name": "grade",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "syfivtki",
"name": "lastCheckIn",
"type": "date",
"system": false,
"required": false,
"options": {
"min": "",
"max": ""
}
},
{
"id": "gwrzizpu",
"name": "company",
"type": "relation",
"system": false,
"required": false,
"options": {
"collectionId": "s854d2w72fvyl54",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": []
}
},
{
"id": "rtvjzpxw",
"name": "shift",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "ma5ckyk1",
"name": "wageFactor",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 1,
"max": null
}
}
],
"indexes": [
"CREATE INDEX `idx_mPdvrAQ` ON `accounts` (`accountNumber`)"
],
"listRule": "",
"viewRule": null,
"createRule": null,
"updateRule": "(@request.data.id = null && @request.data.accountNumber = null && @request.data.firstName = null && @request.data.lastName = null && @request.data.grade = null && @request.data.created = null && @request.data.updated = null && @request.data.company = null && @request.data.shift = null && @request.data.wageFactor = null) || (@request.data.id = null && @request.data.accountNumber = null && @request.data.firstName = null && @request.data.lastName = null && @request.data.created = null && @request.data.updated = null && @request.data.company = null && @request.auth.id = company.id)",
"deleteRule": null,
"options": {}
},
{
"id": "5msnfxat1sc2c1r",
"name": "companyTransactions",
@ -219,79 +106,6 @@
"deleteRule": null,
"options": {}
},
{
"id": "t4gewf713jqhz3i",
"name": "settings",
"type": "base",
"system": false,
"schema": [
{
"id": "z7pmr7wm",
"name": "minWage",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "7wzv9qum",
"name": "incomeTax",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "3pgjcfai",
"name": "incomeTaxRecipient",
"type": "relation",
"system": false,
"required": true,
"options": {
"collectionId": "s854d2w72fvyl54",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": []
}
},
{
"id": "dawtvakx",
"name": "maxWageFactor",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "ftzmu1na",
"name": "radioUrl",
"type": "url",
"system": false,
"required": true,
"options": {
"exceptDomains": [],
"onlyDomains": []
}
}
],
"indexes": [],
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "s854d2w72fvyl54",
"name": "companies",
@ -431,5 +245,191 @@
"options": {
"query": "SELECT\n a.id AS id,\n a.accountNumber AS accountNumber,\n (a.firstName || ' ' || a.lastName) AS name,\n a.grade AS grade,\n a.lastCheckIn AS lastCheckIn,\n SUM(t.amount) AS balance\nFROM\n accounts AS a\nLEFT JOIN\n transactions AS t\nON\n a.id = t.account\nGROUP BY\n a.id\nORDER BY\n a.grade, a.lastName"
}
},
{
"id": "t4gewf713jqhz3i",
"name": "settings",
"type": "base",
"system": false,
"schema": [
{
"id": "z7pmr7wm",
"name": "minWage",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "7wzv9qum",
"name": "incomeTax",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "3pgjcfai",
"name": "incomeTaxRecipient",
"type": "relation",
"system": false,
"required": true,
"options": {
"collectionId": "s854d2w72fvyl54",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": []
}
},
{
"id": "dawtvakx",
"name": "maxWage",
"type": "number",
"system": false,
"required": true,
"options": {
"min": 0,
"max": null
}
},
{
"id": "ftzmu1na",
"name": "radioUrl",
"type": "url",
"system": false,
"required": true,
"options": {
"exceptDomains": [],
"onlyDomains": []
}
}
],
"indexes": [],
"listRule": "",
"viewRule": "",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
},
{
"id": "74ftooxenpeq14b",
"name": "accounts",
"type": "base",
"system": false,
"schema": [
{
"id": "as1gvc1r",
"name": "accountNumber",
"type": "text",
"system": false,
"required": false,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "vxeq20lk",
"name": "firstName",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "bsinpdk7",
"name": "lastName",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "4lw6gmtc",
"name": "grade",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "syfivtki",
"name": "lastCheckIn",
"type": "date",
"system": false,
"required": false,
"options": {
"min": "",
"max": ""
}
},
{
"id": "gwrzizpu",
"name": "company",
"type": "relation",
"system": false,
"required": false,
"options": {
"collectionId": "s854d2w72fvyl54",
"cascadeDelete": false,
"minSelect": null,
"maxSelect": 1,
"displayFields": []
}
},
{
"id": "rtvjzpxw",
"name": "shift",
"type": "text",
"system": false,
"required": true,
"options": {
"min": null,
"max": null,
"pattern": ""
}
},
{
"id": "ggx1gyeq",
"name": "wage",
"type": "number",
"system": false,
"required": false,
"options": {
"min": null,
"max": null
}
}
],
"indexes": [
"CREATE INDEX `idx_mPdvrAQ` ON `accounts` (`accountNumber`)"
],
"listRule": "",
"viewRule": null,
"createRule": null,
"updateRule": "(@request.data.id = null && @request.data.accountNumber = null && @request.data.firstName = null && @request.data.lastName = null && @request.data.grade = null && @request.data.created = null && @request.data.updated = null && @request.data.company = null && @request.data.shift = null && @request.data.wage = null) || (@request.data.id = null && @request.data.accountNumber = null && @request.data.firstName = null && @request.data.lastName = null && @request.data.created = null && @request.data.updated = null && @request.data.company = null && @request.auth.id = company.id)",
"deleteRule": null,
"options": {}
}
]

View file

@ -2,7 +2,7 @@
"name": "hgoe-sas",
"private": true,
"author": "Simon Giesel",
"version": "1.1.0",
"version": "1.2.0",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",

View file

@ -1,24 +1,61 @@
<template>
<div class="relative">
<AtomInput
ref="input"
v-model="modelValue"
<input
:value="value"
type="number"
class="pr-[5.5rem] text-right"
class="input w-full border-primary text-right"
:class="{
'pr-[5.5rem]': !perHour,
'pr-[7rem]': perHour,
}"
step="1"
min="1"
:min="min"
:max="max"
@keyup="onKeyDown"
/>
<span class="pointer-events-none absolute top-2/4 -mt-3 ml-[-5.5rem] opacity-50">,00 Batzen</span>
<span
class="pointer-events-none absolute top-2/4 text-[1rem] opacity-50"
:class="{
'-mt-3': !suffixMargin,
'mt-[-0.625rem]': suffixMargin,
'ml-[-5.5rem]': !perHour,
'ml-[-7rem]': perHour,
}"
>,00 Batzen{{ perHour ? ' / h' : '' }}</span>
</div>
</template>
<script lang="ts" setup>
import AtomInput from './AtomInput.vue';
const props = withDefaults(defineProps<{
suffixMargin?: boolean,
perHour?: boolean,
min?: number,
max?: number,
value?: number,
}>(), {
suffixMargin: false,
perHour: false,
min: 1,
max: 1_000_000,
value: undefined,
});
/* global defineModel */
const modelValue = defineModel();
function onKeyDown(event: KeyboardEvent) {
const value = parseInt((event.target as HTMLInputElement).value);
if(value < props.min) {
emit('change', props.min);
(event.target as HTMLInputElement).value = props.min.toString();
} else if(value > props.max) {
emit('change', props.max);
(event.target as HTMLInputElement).value = props.max.toString();
} else {
emit('change', value);
}
}
const emit = defineEmits<{
change: [value: number],
}>();
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View file

@ -29,12 +29,4 @@ const modelValue = defineModel();
</script>
<style lang="scss" scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
</style>

View file

@ -30,7 +30,7 @@
{{ DateService.toShortString(entry[header.key]) }}
</span>
<span v-else-if="header.type === TableHeaderType.DATETIME">
{{ entry[header.key] ? DateService.toString(parseISO(entry[header.key])): 'N/A' }}
{{ entry[header.key] ? DateService.toString(parseISO(entry[header.key])) : 'N/A' }}
</span>
<span v-else-if="header.type === TableHeaderType.CURRENCY">
{{ CurrencyService.toString(entry[header.key]) }}
@ -40,14 +40,21 @@
<label
tabindex="0"
class="btn bg-base-300 normal-case"
>{{ entry[header.key] }} <ChevronDownIcon class="h-4 w-4" /></label>
>{{ entry[header.key] }}
<ChevronDownIcon class="h-4 w-4" />
</label>
<ul
tabindex="0"
class="dropdown-content menu rounded-box z-[1] w-52 bg-base-100 p-2 shadow"
>
<li @click="$emit('selectShift', {id: entry.id, shift: Shifts.NONE}); removeFocus();"><a>{{ Shifts.NONE }}</a></li>
<li><a @click="$emit('selectShift', {id: entry.id, shift: Shifts.EARLY}); removeFocus();">{{ Shifts.EARLY }}</a></li>
<li><a @click="$emit('selectShift', {id: entry.id, shift: Shifts.LATE}); removeFocus();">{{ Shifts.LATE }}</a></li>
<li @click="$emit('selectShift', { id: entry.id, shift: Shifts.NONE }); removeFocus();"><a>{{
Shifts.NONE
}}</a></li>
<li><a @click="$emit('selectShift', { id: entry.id, shift: Shifts.EARLY }); removeFocus();">{{
Shifts.EARLY }}</a></li>
<li><a @click="$emit('selectShift', { id: entry.id, shift: Shifts.LATE }); removeFocus();">{{
Shifts.LATE
}}</a></li>
</ul>
</div>
</span>
@ -79,21 +86,15 @@
v-if="header.type === TableHeaderType.WAGE"
class="flex items-center gap-2"
>
<div class="dropdown-bottom dropdown">
<label
tabindex="0"
class="btn bg-base-300 normal-case"
>{{ CurrencyService.toString(entry[header.key] * settings.minWage) }} / Tag <ChevronDownIcon class="h-4 w-4" /></label>
<ul
tabindex="0"
class="dropdown-content menu rounded-box z-[1] w-52 bg-base-100 p-2 shadow"
>
<li
v-for="i of settings.maxWageFactor"
@click="$emit('selectWage', {id: entry.id, wageFactor: i}); removeFocus();"
><a>{{ CurrencyService.toString(settings.minWage * i ) }} / Tag</a></li>
</ul>
</div>
<AtomCurrencyInput
:value="entry[header.key] / 100"
suffix-margin
per-hour
class="min-w-[11rem]"
:min="settings.minWage / 100"
:max="settings.maxWage / 100"
@change="$emit('selectWage', { id: entry.id, wage: $event })"
/>
</span>
</td>
</tr>
@ -111,6 +112,7 @@ import { CurrencyService } from '../../services/currency.service';
import { SettingsService } from '../../services/settings.service';
import { Shifts } from '../../enums/shift.enum';
import { ChevronDownIcon, CreditCardIcon, CurrencyDollarIcon } from '@heroicons/vue/24/outline';
import AtomCurrencyInput from '../atoms/AtomCurrencyInput.vue';
const settings = ref<SettingsResponse>();
@ -138,8 +140,8 @@ onMounted(async () => {
defineEmits<{
click: [id: string],
selectShift: [{id: string, shift: Shifts}],
selectWage: [{id: string, wageFactor: number}],
selectShift: [{ id: string, shift: Shifts }],
selectWage: [{ id: string, wage: number }],
changeAccountNumber: [id: string],
}>();
@ -161,6 +163,4 @@ export enum TableHeaderType {
}
</script>
<style lang="scss" scoped>
</style>
<style lang="scss" scoped></style>

View file

@ -127,7 +127,7 @@
:table-headers="accountTableHeaders"
:data="accounts"
@select-shift="selectShift"
@select-wage="selectWage"
@select-wage="updateWage"
/>
</div>
<MoleculeVerifyModal
@ -224,7 +224,7 @@ const accountTableHeaders = [
},
{
title: 'Lohn',
key: 'wageFactor',
key: 'wage',
type: TableHeaderType.WAGE,
},
];
@ -281,7 +281,7 @@ function getBalance(): number {
function getTotalCostsByShifts(shift: Shifts): number {
return accounts.value?.filter(
(account) => account.shift === shift).reduce((acc, account) => acc + account.wageFactor * (settings.value?.minWage ?? 0), 0,
(account) => account.shift === shift).reduce((acc, account) => acc + account.wage, 0,
) ?? 0;
}
@ -308,8 +308,14 @@ async function selectShift(shiftChange: {id: string, shift: Shifts}): Promise<vo
await AccountService.updateShift(shiftChange.id, shiftChange.shift);
}
async function selectWage(wageChange: {id: string, wageFactor: number}): Promise<void> {
await AccountService.updateWage(wageChange.id, wageChange.wageFactor);
async function updateWage(wageChange: {id: string, wage: number}): Promise<void> {
if(wageChange.wage * 100 < (settings.value?.minWage ?? 0)) {
return;
}
if(wageChange.wage * 100 > (settings.value?.maxWage ?? 0)) {
return;
}
await AccountService.updateWage(wageChange.id, wageChange.wage);
}
function logout() {

View file

@ -7,10 +7,12 @@
</template>
<div class="mt-4 flex h-full flex-col gap-4">
<div class="flex place-items-center justify-between">
<span>Mindestlohn pro Tag</span>
<span>Mindestlohn</span>
<AtomCurrencyInput
v-model="minWage"
:value="minWage"
class="w-2/4"
per-hour
@change="minWage = $event"
/>
</div>
<div class="flex place-items-center justify-between">
@ -21,10 +23,13 @@
/>
</div>
<div class="flex place-items-center justify-between">
<span>Höchstlohn-Faktor</span>
<AtomFactorInput
v-model="maxWageFactor"
<span>Höchstlohn</span>
<AtomCurrencyInput
:value="maxWage"
:min="minWage"
per-hour
class="w-2/4"
@change="maxWage = $event"
/>
</div>
<div class="flex place-items-center justify-between">
@ -245,7 +250,6 @@ import AtomCard from '../atoms/AtomCard.vue';
import AtomInput from '../atoms/AtomInput.vue';
import AtomCurrencyInput from '../atoms/AtomCurrencyInput.vue';
import AtomPercentInput from '../atoms/AtomPercentInput.vue';
import AtomFactorInput from '../atoms/AtomFactorInput.vue';
import OrganismAuthWrapper from '../organisms/OrganismAuthWrapper.vue';
import MoleculeImportDataModal from '../molecules/MoleculeImportDataModal.vue';
import MoleculeErrorModal from '../molecules/MoleculeErrorModal.vue';
@ -260,7 +264,7 @@ const verifySeedCapitalModal = ref<InstanceType<typeof MoleculeVerifyModal>>();
const errorModal = ref<InstanceType<typeof MoleculeErrorModal>>();
const minWage = ref<number>();
const incomeTax = ref<number>();
const maxWageFactor = ref<number>();
const maxWage = ref<number>();
const incomeTaxRecipient = ref<string>();
const radioUrl = ref<string>();
const settings = ref<SettingsResponse>();
@ -293,7 +297,6 @@ async function importAccounts(file: File): Promise<void> {
grade: grade.trim(),
company: company?.id,
shift: Shifts.NONE,
wageFactor: 1,
}));
}
if(errors.length) {
@ -382,18 +385,18 @@ onMounted(async () => {
settings.value = await SettingsService.getSettings();
minWage.value = settings.value.minWage / 100;
incomeTax.value = settings.value.incomeTax;
maxWageFactor.value = settings.value.maxWageFactor;
maxWage.value = settings.value.maxWage / 100;
incomeTaxRecipient.value = settings.value.incomeTaxRecipient;
radioUrl.value = settings.value.radioUrl;
serverTime.value = await DateService.getServerTime();
});
async function saveSettings() {
if(minWage.value && incomeTax.value && maxWageFactor.value && incomeTaxRecipient.value && radioUrl.value) {
if(minWage.value && incomeTax.value && maxWage.value && incomeTaxRecipient.value && radioUrl.value) {
settings.value = await SettingsService.setSettings({
minWage: minWage.value,
incomeTax: incomeTax.value,
maxWageFactor: maxWageFactor.value,
maxWage: maxWage.value,
incomeTaxRecipient: incomeTaxRecipient.value,
radioUrl: radioUrl.value,
});

View file

@ -1,3 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind utilities;
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}

View file

@ -30,8 +30,8 @@ export class AccountService {
public static async updateShift(accountId: string, shift: string): Promise<AccountsResponse> {
return COLLECTION.update(accountId, { shift });
}
public static async updateWage(accountId: string, wageFactor: number): Promise<AccountsResponse> {
return COLLECTION.update(accountId, { wageFactor });
public static async updateWage(accountId: string, wage: number): Promise<AccountsResponse> {
return COLLECTION.update(accountId, { wage: wage * 100 });
}
public static async getMissingStudents(): Promise<AccountsResponse[]> {
return ((await COLLECTION.getFullList<AccountsResponse>()).filter(

View file

@ -63,7 +63,7 @@ export class CompanyService {
const settings = await SettingsService.getSettings();
const balance = (await BankService.getCompanyTransactions(company.id)).reduce((acc, transaction) => acc + transaction.amount, 0);
const accounts = (await AccountService.getAccountsByCompanyId(company.id)).filter(account => account.shift == shift);
const totalWages = accounts.reduce((acc, account) => acc + account.wageFactor * (settings.minWage), 0);
const totalWages = accounts.reduce((acc, account) => acc + account.wage, 0);
let taxes = 0;
if(balance >= totalWages) {
await BankService.addTransaction(company.id, -totalWages / 100, `Lohn für ${shift}`, AccountType.COMPANY);
@ -71,10 +71,10 @@ export class CompanyService {
accounts.forEach(async account => {
promises.push(BankService.addTransaction(
account.id,
(((account.wageFactor * settings.minWage) * (1 - (settings.incomeTax / 100)))) / 100,
((account.wage * (1 - (settings.incomeTax / 100)))) / 100,
`Lohn für ${shift} bei ${company.name}`,
));
taxes += (account.wageFactor * settings.minWage) * (settings.incomeTax / 100);
taxes += account.wage * (settings.incomeTax / 100);
});
await Promise.all(promises);
await BankService.addTransaction(settings.incomeTaxRecipient,

View file

@ -29,7 +29,6 @@ export class DateService {
}
public static isToday(date: Date): boolean {
this.getServerTime();
console.log('isToday', date, serverTime);
if(!serverTime) { return false; }
return date.getDate() === serverTime.getDate() &&
date.getMonth() === serverTime.getMonth() &&

View file

@ -9,6 +9,7 @@ export class SettingsService {
}
public static async setSettings(settings: SettingsRecord): Promise<SettingsResponse> {
settings.minWage = settings.minWage * 100;
settings.maxWage = settings.maxWage * 100;
// TODO: Enforce maxWageFactor to all account records
return COLLECTION.create(settings);
}

View file

@ -43,7 +43,7 @@ export type AccountsRecord = {
lastCheckIn?: IsoDateString
company?: RecordIdString
shift: string
wageFactor: number
wage?: number
}
export type AccountsListRecord<Tbalance = unknown, Tname = unknown> = {
@ -71,7 +71,7 @@ export type SettingsRecord = {
minWage: number
incomeTax: number
incomeTaxRecipient: RecordIdString
maxWageFactor: number
maxWage: number
radioUrl: string
}