From f6d145c1c99182e8ecc459c81be1370130cb9156 Mon Sep 17 00:00:00 2001 From: Pavel Baluev Date: Sat, 10 Aug 2024 14:27:36 +0200 Subject: [PATCH] "working-hours" module: add metadata field, update overwork math --- src/client/utils/portal.tsx | 5 +- src/modules/_template/manifest.json | 1 + .../client/components/AdminWorkingHours.tsx | 4 +- .../client/components/DefaultEntriesModal.tsx | 8 +- .../client/components/EntryRow.tsx | 143 ------------------ .../client/components/WorkingHoursEditor.tsx | 47 +++++- .../components/WorkingHoursEditorMonth.tsx | 8 +- .../components/WorkingHoursUserModal.tsx | 4 +- src/modules/working-hours/metadata-schema.ts | 1 + .../jobs/fetch-default-working-hours.ts | 86 +++++++---- ...-hours_add-working-hours-entry-metadata.js | 14 ++ .../server/models/working-hours-entry.ts | 5 + src/modules/working-hours/server/router.ts | 15 +- .../working-hours/shared-helpers/index.ts | 6 +- src/modules/working-hours/types.ts | 1 + 15 files changed, 147 insertions(+), 201 deletions(-) create mode 100644 src/modules/working-hours/server/migrations/20240808181550_working-hours_add-working-hours-entry-metadata.js diff --git a/src/client/utils/portal.tsx b/src/client/utils/portal.tsx index fb7fbb25..e540f99e 100644 --- a/src/client/utils/portal.tsx +++ b/src/client/utils/portal.tsx @@ -2,7 +2,9 @@ import * as React from 'react' import MC from '#client/components/__import-components' import { ComponentRef } from '#shared/types' -export const getComponentInstance = (cr: ComponentRef): React.FC | null => { +export const getComponentInstance = ( + cr: ComponentRef +): React.FC | null => { const moduleId = cr[0] as keyof typeof MC const moduleComponents = MC[moduleId] if (!moduleComponents) return null @@ -26,4 +28,3 @@ export const renderComponent = } return } - diff --git a/src/modules/_template/manifest.json b/src/modules/_template/manifest.json index a512cb33..1208de83 100644 --- a/src/modules/_template/manifest.json +++ b/src/modules/_template/manifest.json @@ -4,6 +4,7 @@ "dependencies": [""], "requiredIntegrations": [], "recommendedIntegrations": [], + "availableCronJobs": [], "models": [], "clientRouter": { "public": {}, diff --git a/src/modules/working-hours/client/components/AdminWorkingHours.tsx b/src/modules/working-hours/client/components/AdminWorkingHours.tsx index c04df2ab..023f52f1 100644 --- a/src/modules/working-hours/client/components/AdminWorkingHours.tsx +++ b/src/modules/working-hours/client/components/AdminWorkingHours.tsx @@ -1,10 +1,8 @@ import * as React from 'react' import dayjs, { Dayjs } from 'dayjs' -import config from '#client/config' import { Button, H1, - Icons, Placeholder, RoundButton, Select, @@ -222,7 +220,7 @@ export const AdminWorkingHours: React.FC = () => { !!x.overworkLevel && !!x.overworkTime && ( - Overwork {getDurationString(x.overworkTime)} + Additional {getDurationString(x.overworkTime)} )} diff --git a/src/modules/working-hours/client/components/DefaultEntriesModal.tsx b/src/modules/working-hours/client/components/DefaultEntriesModal.tsx index eb57a1e3..3efd934b 100644 --- a/src/modules/working-hours/client/components/DefaultEntriesModal.tsx +++ b/src/modules/working-hours/client/components/DefaultEntriesModal.tsx @@ -81,7 +81,7 @@ export const DefaultEntriesModal: React.FC = ({ }, [newEntryTime]) return ( - +

Specify your usual working schedule and use it for prefilling.

@@ -136,11 +136,7 @@ export const DefaultEntriesModal: React.FC = ({
)} {!showNewEntryInput ? ( - + {sortedEntries.length ? 'Add one more entry' : 'Add entry'} ) : ( diff --git a/src/modules/working-hours/client/components/EntryRow.tsx b/src/modules/working-hours/client/components/EntryRow.tsx index c676a4f3..09d19442 100644 --- a/src/modules/working-hours/client/components/EntryRow.tsx +++ b/src/modules/working-hours/client/components/EntryRow.tsx @@ -2,28 +2,15 @@ import * as React from 'react' import { FButton, Icons, - Modal, - P, RoundButton, TimeRangePicker, - showNotification, } from '#client/components/ui' -import * as fp from '#shared/utils/fp' -import { cn } from '#client/utils' import { DefaultWorkingHoursEntry, DefaultWorkingHoursEntryUpdateRequest, - WorkingHoursConfig, WorkingHoursEntry, WorkingHoursEntryUpdateRequest, } from '#shared/types' -import { - useDefaultEntries, - useCreateDefaultEntry, - useDeleteDefaultEntry, - useUpdateDefaultEntry, -} from '../queries' -import { formatTimeString } from '../helpers' type EntryRowProps = { entry: WorkingHoursEntry | DefaultWorkingHoursEntry @@ -97,133 +84,3 @@ export const EntryRow: React.FC = ({
) } - -type DefaultEntriesModalProps = { - onClose: () => void - moduleConfig: WorkingHoursConfig - refetchModuleConfig: () => void -} -const DefaultEntriesModal: React.FC = ({ - onClose, - moduleConfig, - refetchModuleConfig, -}) => { - const newEntryRef = React.useRef(null) - const [showNewEntryInput, setShowNewEntryInput] = React.useState(false) - const [newEntryTime, setNewEntryTime] = React.useState<[string, string]>([ - '', - '', - ]) - - const { data: entries = [], refetch: refetchDefaultEntries } = - useDefaultEntries() - const refetch = () => { - refetchDefaultEntries() - refetchModuleConfig() - showNotification('Your default working hours have changed', 'success') - } - const { mutate: createDefaultEntry } = useCreateDefaultEntry(refetch) - const { mutate: updateDefaultEntry } = useUpdateDefaultEntry(refetch) - const { mutate: deleteDefaultEntry } = useDeleteDefaultEntry(refetch) - - const sortedEntries = React.useMemo(() => { - return entries.sort( - fp.sortWith((x) => { - const [h, m] = x.startTime.split(':').map(Number) - return h * 60 + m - }) - ) - }, [entries]) - - const onAddEntry = React.useCallback(() => { - setShowNewEntryInput(!showNewEntryInput) - if (!showNewEntryInput) { - setTimeout(() => { - const firstTimeInput: HTMLInputElement = - newEntryRef.current?.querySelector('input[type="time"]')! - if (firstTimeInput) firstTimeInput.focus() - }, 100) - } - }, [showNewEntryInput]) - const onChangeNewEntryTime = React.useCallback((from: string, to: string) => { - setNewEntryTime([from, to]) - }, []) - const onSaveNewEntry = React.useCallback(() => { - createDefaultEntry({ - startTime: newEntryTime[0], - endTime: newEntryTime[1], - }) - setShowNewEntryInput(false) - setNewEntryTime(['', '']) - }, [newEntryTime]) - - return ( - -
-
-

Specify your usual working schedule and use it for prefilling.

- {!entries.length && ( -

- If your default working hours are not set, the following will be - used:{' '} - {moduleConfig.defaultEntries - .map( - (x) => `${formatTimeString(x[0])} - ${formatTimeString(x[1])}` - ) - .join(', ')} - . -

- )} -
- {(!!sortedEntries.length || showNewEntryInput) && ( -
- {sortedEntries.map((x) => ( - - ))} - {showNewEntryInput && ( -
- - - - Save - - setShowNewEntryInput(false)} - icon="Cross" - /> -
- )} -
- )} - {!showNewEntryInput ? ( - - {sortedEntries.length ? 'Add one more entry' : 'Add entry'} - - ) : ( -
- )} -
- - ) -} diff --git a/src/modules/working-hours/client/components/WorkingHoursEditor.tsx b/src/modules/working-hours/client/components/WorkingHoursEditor.tsx index 8fb5a797..17d7f99c 100644 --- a/src/modules/working-hours/client/components/WorkingHoursEditor.tsx +++ b/src/modules/working-hours/client/components/WorkingHoursEditor.tsx @@ -1,11 +1,14 @@ import * as React from 'react' +import dayjs from 'dayjs' import { ComponentWrapper, FButton, + HR, HeaderWrapper, Icons, LoaderSpinner, } from '#client/components/ui' +import { renderMarkdown } from '#client/utils/markdown' import { WorkingHoursEditorWeek } from './WorkingHoursEditorWeek' import { WorkingHoursEditorMonth } from './WorkingHoursEditorMonth' import { DefaultEntriesModal } from './DefaultEntriesModal' @@ -16,7 +19,16 @@ import { useConfig } from '../queries' import { formatTimeString } from '../helpers' export const WorkingHoursEditor: React.FC = () => { - const [viewMode, setViewMode] = React.useState<'week' | 'month'>('week') + const [viewMode, setViewMode] = React.useState<'week' | 'month'>( + (() => { + const url = new URL(window.location.href) + const view = url.searchParams.get('view') + if (view === 'month') { + return 'month' + } + return 'week' + })() + ) const [showDefaultEntriesModal, setShowDefaultEntriesModal] = React.useState(false) @@ -34,20 +46,43 @@ export const WorkingHoursEditor: React.FC = () => { React.useEffect(() => { const url = new URL(window.location.href) - if (url.searchParams.get('view') === 'month') { - setViewMode('month') + const view = url.searchParams.get('view') + if (view !== viewMode) { + url.searchParams.set('view', viewMode) + window.history.replaceState({}, '', url.toString()) } - }, []) + }, [viewMode]) return !!moduleConfig ? (
{/* header */} + {moduleConfig.policyText && ( + <> +
+
+ + )} + +
+ Your agreed working week: {moduleConfig.weeklyWorkingHours}h ( + {moduleConfig.workingDays + .map((x) => dayjs().day(x).format('dddd')) + .join(', ')} + ) +
+ + {/* {moduleConfig.policyText && <>} */}
{moduleConfig.personalDefaultEntries.length ? (
- Your default working hours:{' '} + Your schedule:{' '} {moduleConfig.personalDefaultEntries .sort( fp.sortWith((x) => { @@ -76,7 +111,7 @@ export const WorkingHoursEditor: React.FC = () => { onClick={() => setShowDefaultEntriesModal(true)} className="w-full" > - Configure your default working hours + Configure your default working schedule
)} diff --git a/src/modules/working-hours/client/components/WorkingHoursEditorMonth.tsx b/src/modules/working-hours/client/components/WorkingHoursEditorMonth.tsx index 4e6102e4..e71663ff 100644 --- a/src/modules/working-hours/client/components/WorkingHoursEditorMonth.tsx +++ b/src/modules/working-hours/client/components/WorkingHoursEditorMonth.tsx @@ -308,7 +308,7 @@ export const WorkingHoursEditorMonth: React.FC<{ {getDurationString(x.workingHours)} {!!x.overworkLevel && !!x.overworkTime && ( - Overwork {getDurationString(x.overworkTime)} + Additional {getDurationString(x.overworkTime)} )}
@@ -402,14 +402,14 @@ export const WorkingHoursEditorMonth: React.FC<{
-
Agreed working week: {moduleConfig.weeklyWorkingHours}h
+ {/*
Agreed working week: {moduleConfig.weeklyWorkingHours}h
*/}
setShowEntries(Boolean(v))} - inlineLabel="Show More Details" + inlineLabel="Show more details" />
@@ -434,7 +434,7 @@ export const WorkingHoursEditorMonth: React.FC<{ : '–', }, { - Header: 'Overwork', + Header: 'Additional hours', accessor: () => totalPerMonth.overworkTime ? getDurationString(totalPerMonth.overworkTime) diff --git a/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx b/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx index 2601c696..609aeeac 100644 --- a/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx +++ b/src/modules/working-hours/client/components/WorkingHoursUserModal.tsx @@ -273,7 +273,7 @@ export const WorkingHoursUserModal: React.FC<{ {getDurationString(x.workingHours)} {!!x.overworkLevel && !!x.overworkTime && ( - Overwork {getDurationString(x.overworkTime)} + Additional {getDurationString(x.overworkTime)} )}
@@ -347,7 +347,7 @@ export const WorkingHoursUserModal: React.FC<{ type="checkbox" checked={showEntries} onChange={(v) => setShowEntries(Boolean(v))} - inlineLabel="Show More Details" + inlineLabel="Show more details" />
diff --git a/src/modules/working-hours/metadata-schema.ts b/src/modules/working-hours/metadata-schema.ts index af92e584..e5553d1a 100644 --- a/src/modules/working-hours/metadata-schema.ts +++ b/src/modules/working-hours/metadata-schema.ts @@ -22,6 +22,7 @@ export const roleConfigSchema = z nextWeeks: z.number().min(0).max(18), }), publicHolidayCalendarId: z.string().optional(), + policyText: z.string().optional(), }) .strict() diff --git a/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts b/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts index abfe5482..2a3969d8 100644 --- a/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts +++ b/src/modules/working-hours/server/jobs/fetch-default-working-hours.ts @@ -62,46 +62,56 @@ async function fetchHumaansDefaultWorkingHours(ctx: CronJobContext) { const dailyWorkingHours = config.workingDays.length ? config.weeklyWorkingHours / config.workingDays.length : 0 - const workingDaysNumber = employee.workingDays.length - const workingDays = employee.workingDays + const baseConfig = { + workingDays: config.workingDays, + weeklyWorkingHours: config.weeklyWorkingHours, + } + + const employeeWorkingDaysNumber = employee.workingDays.length + const employeeWorkingDays = employee.workingDays .map((x) => WORKING_DAY_INDEX_BY_NAME[x.day]) .filter((x) => x !== undefined) - const weeklyWorkingHours = dailyWorkingHours * workingDaysNumber + const employeeWeeklyWorkingHours = + dailyWorkingHours * employeeWorkingDaysNumber + const employeeConfig = { + workingDays: employeeWorkingDays, + weeklyWorkingHours: employeeWeeklyWorkingHours, + } const userConfig = userConfigByUserId[user.id] - if ( - userConfig && - weeklyWorkingHours !== userConfig.value.weeklyWorkingHours - ) { - // Update config - try { - await userConfig - .set({ - value: { - weeklyWorkingHours, - workingDays, - }, - }) - .save() - ctx.log.info(`Updated time tracking user config for ${user.email}`) - report.succeeded++ - } catch (err) { - ctx.log.error(err, `Failed to update user config`) - report.failed++ + if (userConfig) { + if (compareConfigs(userConfig.value, employeeConfig)) { + // Delete config + try { + await userConfig.destroy() + ctx.log.info(`Deleted time tracking user config for ${user.email}`) + report.succeeded++ + } catch (err) { + ctx.log.error(err, `Failed to delete user config`) + report.failed++ + } + } else if (!compareConfigs(userConfig.value, employeeConfig)) { + // Update config + try { + await userConfig + .set({ + value: employeeConfig, + }) + .save() + ctx.log.info(`Updated time tracking user config for ${user.email}`) + report.succeeded++ + } catch (err) { + ctx.log.error(err, `Failed to update user config`) + report.failed++ + } } - } else if ( - !userConfig && - weeklyWorkingHours !== config.weeklyWorkingHours - ) { - // Create new config + } else if (!compareConfigs(baseConfig, employeeConfig)) { + // Create config try { await ctx.models.WorkingHoursUserConfig.create({ userId: user.id, - value: { - weeklyWorkingHours, - workingDays, - }, + value: employeeConfig, }) ctx.log.info(`Created new time tracking user config for ${user.email}`) report.succeeded++ @@ -111,6 +121,7 @@ async function fetchHumaansDefaultWorkingHours(ctx: CronJobContext) { } } } + if (report.succeeded || report.failed) { ctx.log.info( `Successfully processed ${report.succeeded} working-hours configs. ${report.failed} failed.` @@ -199,3 +210,16 @@ async function fetchBamboHRDefaultWorkingHours(ctx: CronJobContext) { ) } } + +function compareArrays(a: any[] = [], b: any[] = []): boolean { + return a.length === b.length && a.every((x) => b.includes(x)) +} + +function compareConfigs< + T extends { workingDays: number[]; weeklyWorkingHours: number } +>(a: T, b: T): boolean { + return ( + a.weeklyWorkingHours === b.weeklyWorkingHours && + compareArrays(a.workingDays, b.workingDays) + ) +} diff --git a/src/modules/working-hours/server/migrations/20240808181550_working-hours_add-working-hours-entry-metadata.js b/src/modules/working-hours/server/migrations/20240808181550_working-hours_add-working-hours-entry-metadata.js new file mode 100644 index 00000000..bb59ca47 --- /dev/null +++ b/src/modules/working-hours/server/migrations/20240808181550_working-hours_add-working-hours-entry-metadata.js @@ -0,0 +1,14 @@ +// @ts-check +const { Sequelize, DataTypes } = require('sequelize') + +module.exports = { + async up({ context: queryInterface, appConfig }) { + await queryInterface.addColumn('working_hours_entries', 'metadata', { + type: DataTypes.JSONB, + defaultValue: {}, + }) + }, + async down({ context: queryInterface, appConfig }) { + await queryInterface.removeColumn('working_hours_entries', 'metadata') + }, +} diff --git a/src/modules/working-hours/server/models/working-hours-entry.ts b/src/modules/working-hours/server/models/working-hours-entry.ts index b892d37b..952a4789 100644 --- a/src/modules/working-hours/server/models/working-hours-entry.ts +++ b/src/modules/working-hours/server/models/working-hours-entry.ts @@ -19,6 +19,7 @@ export class WorkingHoursEntry declare date: WorkingHoursEntryModel['date'] declare startTime: WorkingHoursEntryModel['startTime'] declare endTime: WorkingHoursEntryModel['endTime'] + declare metadata: WorkingHoursEntryModel['metadata'] } WorkingHoursEntry.init( @@ -50,6 +51,10 @@ WorkingHoursEntry.init( type: DataTypes.STRING, allowNull: false, }, + metadata: { + type: DataTypes.JSONB, + defaultValue: {}, + }, createdAt: DataTypes.DATE, updatedAt: DataTypes.DATE, }, diff --git a/src/modules/working-hours/server/router.ts b/src/modules/working-hours/server/router.ts index 14687e35..b99a37ee 100644 --- a/src/modules/working-hours/server/router.ts +++ b/src/modules/working-hours/server/router.ts @@ -82,6 +82,15 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { x.endTime, ]) } + + const userConfig = await fastify.db.WorkingHoursUserConfig.findOne({ + where: { userId: req.user.id }, + }) + if (userConfig) { + result.workingDays = userConfig.value.workingDays + result.weeklyWorkingHours = userConfig.value.weeklyWorkingHours + } + return result }) @@ -177,7 +186,7 @@ const userRouter: FastifyPluginCallback = async function (fastify, opts) { return reply.throw.accessDenied() } const newEntries: Array< - Omit + Pick > = req.body.map((x) => ({ ...x, userId: req.user.id })) let error: string | null = null @@ -716,7 +725,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { 'Week end', 'Working time', // 'Working hours', - 'Overwork', + 'Additional hours', 'Entries', 'Entry creation date', 'Time Off', @@ -954,7 +963,7 @@ const adminRouter: FastifyPluginCallback = async function (fastify, opts) { 'Week end', 'Working time', // 'Working hours', - 'Overwork', + 'Additional hours', 'Entries', 'Entry creation date', 'Time Off', diff --git a/src/modules/working-hours/shared-helpers/index.ts b/src/modules/working-hours/shared-helpers/index.ts index 3484714b..5b7326be 100644 --- a/src/modules/working-hours/shared-helpers/index.ts +++ b/src/modules/working-hours/shared-helpers/index.ts @@ -165,8 +165,12 @@ export function calculateTotalPublicHolidaysTime( if (!config || !holidays.length) { return null } + const dates = holidays.filter((x) => { + const date = dayjs(x.date, DATE_FORMAT) + return config.workingDays.includes(date.day()) + }) const hoursPerDay = config.weeklyWorkingHours / config.workingDays.length - const resultHours = holidays.length * hoursPerDay + const resultHours = dates.length * hoursPerDay const resultMinutes = (resultHours % 1) * 60 return !!resultHours ? [Math.floor(resultHours), Math.floor(resultMinutes)] diff --git a/src/modules/working-hours/types.ts b/src/modules/working-hours/types.ts index 43a5531f..bf1460c8 100644 --- a/src/modules/working-hours/types.ts +++ b/src/modules/working-hours/types.ts @@ -6,6 +6,7 @@ export interface WorkingHoursEntry { date: string startTime: string endTime: string + metadata: Record createdAt: Date updatedAt: Date }