@open-mercato/core 0.6.8-develop.6972.1.275e435508 → 0.6.8-develop.6974.1.43440f247a
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/staff/backend/staff/timesheets/page.js +182 -112
- package/dist/modules/staff/backend/staff/timesheets/page.js.map +2 -2
- package/dist/modules/staff/lib/timesheetsDuration.js +45 -0
- package/dist/modules/staff/lib/timesheetsDuration.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/staff/backend/staff/timesheets/page.tsx +127 -38
- package/src/modules/staff/i18n/de.json +6 -0
- package/src/modules/staff/i18n/en.json +6 -0
- package/src/modules/staff/i18n/es.json +6 -0
- package/src/modules/staff/i18n/ko.json +6 -0
- package/src/modules/staff/i18n/pl.json +6 -0
- package/src/modules/staff/lib/timesheetsDuration.ts +56 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
5
5
|
import { Button } from '@open-mercato/ui/primitives/button'
|
|
6
|
+
import { InlineInput } from '@open-mercato/ui/primitives/inline-input'
|
|
6
7
|
import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
7
8
|
import { useGuardedMutation } from '@open-mercato/ui/backend/injection/useGuardedMutation'
|
|
8
9
|
import { flash } from '@open-mercato/ui/backend/FlashMessages'
|
|
@@ -19,6 +20,11 @@ import { TimerBar } from '../../../lib/timesheets-ui/TimerBar'
|
|
|
19
20
|
import { AddRowDropdown } from '../../../lib/timesheets-ui/AddRowDropdown'
|
|
20
21
|
import { CreateProjectDialog } from '../../../lib/timesheets-ui/CreateProjectDialog'
|
|
21
22
|
import { ProjectColorDot } from '../../../lib/timesheets-ui/ProjectColorDot'
|
|
23
|
+
import {
|
|
24
|
+
formatMinutesAsDecimal,
|
|
25
|
+
parseDurationInput,
|
|
26
|
+
type DurationParseError,
|
|
27
|
+
} from '../../../lib/timesheetsDuration'
|
|
22
28
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
23
29
|
|
|
24
30
|
const logger = createLogger('staff')
|
|
@@ -30,6 +36,7 @@ type CellEntry = { id?: string; minutes: number }
|
|
|
30
36
|
type EntryMap = Record<string, Record<string, CellEntry[]>>
|
|
31
37
|
type DirtyMap = Record<string, Record<string, CellEntry>>
|
|
32
38
|
type RawTextMap = Record<string, Record<string, string>>
|
|
39
|
+
type CellErrorMap = Record<string, Record<string, DurationParseError>>
|
|
33
40
|
type ViewMode = 'weekly' | 'monthly'
|
|
34
41
|
type ViewType = 'timesheet' | 'list'
|
|
35
42
|
|
|
@@ -81,24 +88,14 @@ function isWeekendDay(date: Date): boolean {
|
|
|
81
88
|
return d === 0 || d === 6
|
|
82
89
|
}
|
|
83
90
|
|
|
84
|
-
function minutesToDecimal(minutes: number): string {
|
|
85
|
-
if (minutes === 0) return ''
|
|
86
|
-
const hours = minutes / 60
|
|
87
|
-
return hours % 1 === 0 ? String(hours) : hours.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function decimalToMinutes(value: string): number {
|
|
91
|
-
const trimmed = value.trim()
|
|
92
|
-
if (!trimmed) return 0
|
|
93
|
-
const num = parseFloat(trimmed.replace(',', '.'))
|
|
94
|
-
if (isNaN(num) || num < 0) return 0
|
|
95
|
-
return Math.min(Math.round(num * 60), 1440)
|
|
96
|
-
}
|
|
97
|
-
|
|
98
91
|
function getLocalizedDayName(date: Date): string {
|
|
99
92
|
return date.toLocaleDateString(undefined, { weekday: 'short' })
|
|
100
93
|
}
|
|
101
94
|
|
|
95
|
+
function getLocalizedCellDate(date: Date): string {
|
|
96
|
+
return date.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
|
|
97
|
+
}
|
|
98
|
+
|
|
102
99
|
// --- Derived date ranges ---
|
|
103
100
|
|
|
104
101
|
function getWeekDays(weekStart: Date): Date[] {
|
|
@@ -156,6 +153,7 @@ export default function MyTimesheetsPage() {
|
|
|
156
153
|
const [rawEntries, setRawEntries] = React.useState<RawTimeEntry[]>([])
|
|
157
154
|
const [dirty, setDirty] = React.useState<DirtyMap>({})
|
|
158
155
|
const [rawText, setRawText] = React.useState<RawTextMap>({})
|
|
156
|
+
const [cellErrors, setCellErrors] = React.useState<CellErrorMap>({})
|
|
159
157
|
const [staffMemberId, setStaffMemberId] = React.useState<string | null>(null)
|
|
160
158
|
const [staffMemberMissing, setStaffMemberMissing] = React.useState(false)
|
|
161
159
|
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
|
|
@@ -180,6 +178,21 @@ export default function MyTimesheetsPage() {
|
|
|
180
178
|
blockedMessage: t('ui.forms.flash.saveBlocked', 'Save blocked by validation'),
|
|
181
179
|
})
|
|
182
180
|
|
|
181
|
+
const durationFormatHint = t(
|
|
182
|
+
'staff.timesheets.my.duration.hint',
|
|
183
|
+
'Enter hours (8 or 1.5), h:mm (1:30) or minutes (90m). Max 24h per day.',
|
|
184
|
+
)
|
|
185
|
+
const describeDurationCell = React.useCallback((projectName: string, date: Date): string => (
|
|
186
|
+
t('staff.timesheets.my.duration.cellLabel', 'Duration for {project} on {date}')
|
|
187
|
+
.replace('{project}', projectName)
|
|
188
|
+
.replace('{date}', getLocalizedCellDate(date))
|
|
189
|
+
), [t])
|
|
190
|
+
const describeDurationError = React.useCallback((reason: DurationParseError): string => (
|
|
191
|
+
reason === 'out_of_range'
|
|
192
|
+
? t('staff.timesheets.my.duration.errors.out_of_range', 'Maximum 24h per day. Use 1:30 or 90m for shorter entries.')
|
|
193
|
+
: t('staff.timesheets.my.duration.errors.invalid', 'Unrecognised duration. Use 8, 1.5, 1:30, 90m or 1h 30m.')
|
|
194
|
+
), [t])
|
|
195
|
+
|
|
183
196
|
// --- Feature check ---
|
|
184
197
|
React.useEffect(() => {
|
|
185
198
|
let cancelled = false
|
|
@@ -343,6 +356,7 @@ export default function MyTimesheetsPage() {
|
|
|
343
356
|
setEntries(map)
|
|
344
357
|
setDirty({})
|
|
345
358
|
setRawText({})
|
|
359
|
+
setCellErrors({})
|
|
346
360
|
} catch (error) {
|
|
347
361
|
logger.error('staff.timesheets.my.load', { err: error })
|
|
348
362
|
flash(t('staff.timesheets.my.errors.load', 'Failed to load timesheets.'), 'error')
|
|
@@ -358,21 +372,57 @@ export default function MyTimesheetsPage() {
|
|
|
358
372
|
}, [loadData, scopeVersion])
|
|
359
373
|
|
|
360
374
|
// --- Cell handlers ---
|
|
375
|
+
const clearCellError = React.useCallback((projectId: string, dateKey: string) => {
|
|
376
|
+
setCellErrors((prev) => {
|
|
377
|
+
const projectErrors = prev[projectId]
|
|
378
|
+
if (!projectErrors || projectErrors[dateKey] === undefined) return prev
|
|
379
|
+
const nextProjectErrors = { ...projectErrors }
|
|
380
|
+
delete nextProjectErrors[dateKey]
|
|
381
|
+
const next = { ...prev }
|
|
382
|
+
if (Object.keys(nextProjectErrors).length > 0) next[projectId] = nextProjectErrors
|
|
383
|
+
else delete next[projectId]
|
|
384
|
+
return next
|
|
385
|
+
})
|
|
386
|
+
}, [])
|
|
387
|
+
|
|
361
388
|
const handleCellChange = React.useCallback((projectId: string, dateKey: string, value: string) => {
|
|
362
|
-
|
|
363
|
-
const sanitized = value.replace(/[^0-9.,]/g, '')
|
|
389
|
+
clearCellError(projectId, dateKey)
|
|
364
390
|
setRawText((prev) => {
|
|
365
391
|
const projectTexts = { ...(prev[projectId] ?? {}) }
|
|
366
|
-
projectTexts[dateKey] =
|
|
392
|
+
projectTexts[dateKey] = value
|
|
367
393
|
return { ...prev, [projectId]: projectTexts }
|
|
368
394
|
})
|
|
395
|
+
}, [clearCellError])
|
|
396
|
+
|
|
397
|
+
const dropDirtyCell = React.useCallback((projectId: string, dateKey: string) => {
|
|
398
|
+
setDirty((prev) => {
|
|
399
|
+
const projectEntries = prev[projectId]
|
|
400
|
+
if (!projectEntries || projectEntries[dateKey] === undefined) return prev
|
|
401
|
+
const nextProjectEntries = { ...projectEntries }
|
|
402
|
+
delete nextProjectEntries[dateKey]
|
|
403
|
+
const next = { ...prev }
|
|
404
|
+
if (Object.keys(nextProjectEntries).length > 0) next[projectId] = nextProjectEntries
|
|
405
|
+
else delete next[projectId]
|
|
406
|
+
return next
|
|
407
|
+
})
|
|
369
408
|
}, [])
|
|
370
409
|
|
|
371
410
|
const handleCellBlur = React.useCallback((projectId: string, dateKey: string, currentValue: string) => {
|
|
372
411
|
const editedText = rawText[projectId]?.[dateKey]
|
|
373
412
|
const text = editedText ?? currentValue
|
|
374
413
|
if (text === undefined) return
|
|
375
|
-
const
|
|
414
|
+
const parsed = parseDurationInput(text)
|
|
415
|
+
if (!parsed.ok) {
|
|
416
|
+
dropDirtyCell(projectId, dateKey)
|
|
417
|
+
setCellErrors((prev) => {
|
|
418
|
+
const projectErrors = { ...(prev[projectId] ?? {}) }
|
|
419
|
+
projectErrors[dateKey] = parsed.reason
|
|
420
|
+
return { ...prev, [projectId]: projectErrors }
|
|
421
|
+
})
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
clearCellError(projectId, dateKey)
|
|
425
|
+
const minutes = parsed.minutes
|
|
376
426
|
const cellEntries = entries[projectId]?.[dateKey] ?? []
|
|
377
427
|
const existingMinutes = cellEntries.reduce((sum, e) => sum + e.minutes, 0)
|
|
378
428
|
|
|
@@ -395,7 +445,7 @@ export default function MyTimesheetsPage() {
|
|
|
395
445
|
}
|
|
396
446
|
return { ...prev, [projectId]: projectTexts }
|
|
397
447
|
})
|
|
398
|
-
}, [rawText, entries])
|
|
448
|
+
}, [rawText, entries, clearCellError, dropDirtyCell])
|
|
399
449
|
|
|
400
450
|
const getCellValue = React.useCallback((projectId: string, dateKey: string): number => {
|
|
401
451
|
const dirtyCell = dirty[projectId]?.[dateKey] as CellEntry | undefined
|
|
@@ -406,12 +456,30 @@ export default function MyTimesheetsPage() {
|
|
|
406
456
|
|
|
407
457
|
// --- Save ---
|
|
408
458
|
const hasChanges = Object.keys(dirty).length > 0 || Object.keys(rawText).length > 0
|
|
459
|
+
const invalidCellCount = React.useMemo(
|
|
460
|
+
() => Object.values(cellErrors).reduce((sum, projectErrors) => sum + Object.keys(projectErrors).length, 0),
|
|
461
|
+
[cellErrors],
|
|
462
|
+
)
|
|
463
|
+
const pendingSummary = React.useMemo(() => {
|
|
464
|
+
let cells = 0
|
|
465
|
+
let minutes = 0
|
|
466
|
+
for (const dateMap of Object.values(dirty)) {
|
|
467
|
+
for (const cell of Object.values(dateMap)) {
|
|
468
|
+
cells += 1
|
|
469
|
+
minutes += cell.minutes
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return { cells, minutes }
|
|
473
|
+
}, [dirty])
|
|
409
474
|
|
|
410
475
|
const handleSave = React.useCallback(async () => {
|
|
411
|
-
if (!hasChanges) return
|
|
476
|
+
if (!hasChanges || invalidCellCount > 0) return
|
|
477
|
+
const summary = t('staff.timesheets.my.confirm_save.summary', '{count} cell(s), {total}h in total.')
|
|
478
|
+
.replace('{count}', String(pendingSummary.cells))
|
|
479
|
+
.replace('{total}', formatMinutesAsDecimal(pendingSummary.minutes) || '0')
|
|
412
480
|
const confirmed = await confirm({
|
|
413
481
|
title: t('staff.timesheets.my.confirm_save.title', 'Save changes?'),
|
|
414
|
-
text: t('staff.timesheets.my.confirm_save.body', 'Your timesheet entries will be saved.')
|
|
482
|
+
text: `${t('staff.timesheets.my.confirm_save.body', 'Your timesheet entries will be saved.')} ${summary}`,
|
|
415
483
|
})
|
|
416
484
|
if (!confirmed) return
|
|
417
485
|
|
|
@@ -454,7 +522,7 @@ export default function MyTimesheetsPage() {
|
|
|
454
522
|
} finally {
|
|
455
523
|
setIsSaving(false)
|
|
456
524
|
}
|
|
457
|
-
}, [dirty, entries, hasChanges, confirm, t, loadData, runMutation, mutationContextId, staffMemberId, retryLastMutation])
|
|
525
|
+
}, [dirty, entries, hasChanges, invalidCellCount, pendingSummary, confirm, t, loadData, runMutation, mutationContextId, staffMemberId, retryLastMutation])
|
|
458
526
|
|
|
459
527
|
// --- Totals ---
|
|
460
528
|
const getRowTotal = React.useCallback((projectId: string): number => {
|
|
@@ -612,6 +680,12 @@ export default function MyTimesheetsPage() {
|
|
|
612
680
|
delete next[project.id]
|
|
613
681
|
return next
|
|
614
682
|
})
|
|
683
|
+
setCellErrors((prev) => {
|
|
684
|
+
if (!prev[project.id]) return prev
|
|
685
|
+
const next = { ...prev }
|
|
686
|
+
delete next[project.id]
|
|
687
|
+
return next
|
|
688
|
+
})
|
|
615
689
|
} catch (error) {
|
|
616
690
|
logger.error('staff.timesheets.my.removeRow', { err: error })
|
|
617
691
|
flash(t('staff.timesheets.my.removeRow.error', 'Could not remove the project. Please try again.'), 'error')
|
|
@@ -692,7 +766,7 @@ export default function MyTimesheetsPage() {
|
|
|
692
766
|
? t('staff.timesheets.my.weekTotal', 'Week Total')
|
|
693
767
|
: t('staff.timesheets.my.total_hours', 'Total Hours')}
|
|
694
768
|
</p>
|
|
695
|
-
<p className="text-2xl font-semibold">{
|
|
769
|
+
<p className="text-2xl font-semibold">{formatMinutesAsDecimal(grandTotal) || '0'}</p>
|
|
696
770
|
</div>
|
|
697
771
|
<div className="rounded-lg border bg-card p-4">
|
|
698
772
|
<p className="text-sm text-muted-foreground">{t('staff.timesheets.my.working_days', 'Working Days')}</p>
|
|
@@ -700,7 +774,7 @@ export default function MyTimesheetsPage() {
|
|
|
700
774
|
</div>
|
|
701
775
|
<div className="rounded-lg border bg-card p-4">
|
|
702
776
|
<p className="text-sm text-muted-foreground">{t('staff.timesheets.my.daily_average', 'Daily Average')}</p>
|
|
703
|
-
<p className="text-2xl font-semibold">{
|
|
777
|
+
<p className="text-2xl font-semibold">{formatMinutesAsDecimal(Math.round(dailyAverage)) || '0'}</p>
|
|
704
778
|
</div>
|
|
705
779
|
<div className="rounded-lg border bg-card p-4">
|
|
706
780
|
<p className="text-sm text-muted-foreground">{t('staff.timesheets.my.status', 'Status')}</p>
|
|
@@ -735,12 +809,16 @@ export default function MyTimesheetsPage() {
|
|
|
735
809
|
onViewTypeChange={setViewType}
|
|
736
810
|
/>
|
|
737
811
|
<div className="flex items-center gap-2">
|
|
738
|
-
{
|
|
739
|
-
<span className="text-xs text-
|
|
812
|
+
{invalidCellCount > 0 ? (
|
|
813
|
+
<span className="text-xs text-status-error-text font-medium">
|
|
814
|
+
{t('staff.timesheets.my.duration.blocked', 'Fix the highlighted durations to save')}
|
|
815
|
+
</span>
|
|
816
|
+
) : hasChanges && (
|
|
817
|
+
<span className="text-xs text-status-warning-text font-medium">
|
|
740
818
|
{t('staff.timesheets.my.unsaved', 'Unsaved changes')}
|
|
741
819
|
</span>
|
|
742
820
|
)}
|
|
743
|
-
<Button size="sm" type="button" onClick={handleSave} disabled={!hasChanges || isSaving}>
|
|
821
|
+
<Button size="sm" type="button" onClick={handleSave} disabled={!hasChanges || isSaving || invalidCellCount > 0}>
|
|
744
822
|
{isSaving ? t('staff.timesheets.my.saving', 'Saving...') : t('staff.timesheets.my.save_changes', 'Save Changes')}
|
|
745
823
|
</Button>
|
|
746
824
|
</div>
|
|
@@ -839,30 +917,40 @@ export default function MyTimesheetsPage() {
|
|
|
839
917
|
const weekend = isWeekendDay(date)
|
|
840
918
|
const cellMinutes = getCellValue(project.id, dateKey)
|
|
841
919
|
const isDirty = dirty[project.id]?.[dateKey] !== undefined
|
|
920
|
+
const cellError = cellErrors[project.id]?.[dateKey]
|
|
921
|
+
const cellErrorMessage = cellError ? describeDurationError(cellError) : undefined
|
|
842
922
|
return (
|
|
843
923
|
<td key={dateKey} className={`px-0.5 py-0.5 ${weekend ? 'bg-muted/40' : ''}`}>
|
|
844
924
|
{weekend ? (
|
|
845
925
|
<div className="rounded px-1 py-1 text-center text-xs text-muted-foreground/50">-</div>
|
|
846
926
|
) : (
|
|
847
|
-
<
|
|
927
|
+
<InlineInput
|
|
848
928
|
type="text"
|
|
849
|
-
inputMode="
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
${
|
|
853
|
-
${
|
|
854
|
-
|
|
855
|
-
|
|
929
|
+
inputMode="text"
|
|
930
|
+
showBorderOnHover={false}
|
|
931
|
+
className={`mx-auto flex h-6 border transition-colors
|
|
932
|
+
${viewMode === 'weekly' ? 'w-12 px-1' : 'w-8 px-0'}
|
|
933
|
+
${cellError
|
|
934
|
+
? 'bg-status-error-bg'
|
|
935
|
+
: isDirty
|
|
936
|
+
? 'border-status-warning-border bg-status-warning-bg'
|
|
937
|
+
: 'border-muted-foreground/20 bg-transparent hover:border-muted-foreground/40'}`}
|
|
938
|
+
inputClassName={`text-center text-xs tabular-nums
|
|
939
|
+
${cellError ? 'text-status-error-text' : cellMinutes > 0 ? 'font-semibold' : 'text-muted-foreground'}`}
|
|
940
|
+
value={rawText[project.id]?.[dateKey] ?? formatMinutesAsDecimal(cellMinutes)}
|
|
856
941
|
onChange={(e) => handleCellChange(project.id, dateKey, e.target.value)}
|
|
857
942
|
onBlur={(event) => handleCellBlur(project.id, dateKey, event.currentTarget.value)}
|
|
858
943
|
placeholder={t('staff.timesheets.my.durationPlaceholder', '0')}
|
|
944
|
+
aria-invalid={cellError !== undefined}
|
|
945
|
+
aria-label={describeDurationCell(project.name, date)}
|
|
946
|
+
title={cellErrorMessage ?? durationFormatHint}
|
|
859
947
|
/>
|
|
860
948
|
)}
|
|
861
949
|
</td>
|
|
862
950
|
)
|
|
863
951
|
})}
|
|
864
952
|
<td className="px-3 py-1.5 text-right font-semibold text-xs tabular-nums">
|
|
865
|
-
{
|
|
953
|
+
{formatMinutesAsDecimal(getRowTotal(project.id)) || '0'}
|
|
866
954
|
</td>
|
|
867
955
|
</tr>
|
|
868
956
|
))}
|
|
@@ -888,14 +976,15 @@ export default function MyTimesheetsPage() {
|
|
|
888
976
|
const dayMinutes = getDayTotal(date)
|
|
889
977
|
return (
|
|
890
978
|
<td key={formatDateKey(date)} className={`py-2 text-center text-xs tabular-nums ${weekend ? 'text-muted-foreground/50' : ''}`}>
|
|
891
|
-
{weekend ? '-' : (
|
|
979
|
+
{weekend ? '-' : (formatMinutesAsDecimal(dayMinutes) || '-')}
|
|
892
980
|
</td>
|
|
893
981
|
)
|
|
894
982
|
})}
|
|
895
|
-
<td className="px-3 py-2 text-right tabular-nums font-semibold">{
|
|
983
|
+
<td className="px-3 py-2 text-right tabular-nums font-semibold">{formatMinutesAsDecimal(grandTotal) || '0'}</td>
|
|
896
984
|
</tr>
|
|
897
985
|
</tfoot>
|
|
898
986
|
</table>
|
|
987
|
+
<p className="px-3 py-2 text-xs text-muted-foreground">{durationFormatHint}</p>
|
|
899
988
|
</div>
|
|
900
989
|
)}
|
|
901
990
|
</div>
|
|
@@ -967,10 +967,16 @@
|
|
|
967
967
|
"staff.timesheets.my.calendar.prevMonth": "Previous month",
|
|
968
968
|
"staff.timesheets.my.calendar.thisWeek": "This week",
|
|
969
969
|
"staff.timesheets.my.confirm_save.body": "Your timesheet entries will be saved.",
|
|
970
|
+
"staff.timesheets.my.confirm_save.summary": "{count} Zelle(n), insgesamt {total} h.",
|
|
970
971
|
"staff.timesheets.my.confirm_save.title": "Save changes?",
|
|
971
972
|
"staff.timesheets.my.createProfile": "Create Team Member Profile",
|
|
972
973
|
"staff.timesheets.my.daily_average": "Daily Average",
|
|
973
974
|
"staff.timesheets.my.daily_total": "Daily Total",
|
|
975
|
+
"staff.timesheets.my.duration.blocked": "Markierte Zeiten korrigieren, um zu speichern",
|
|
976
|
+
"staff.timesheets.my.duration.cellLabel": "Dauer für {project} am {date}",
|
|
977
|
+
"staff.timesheets.my.duration.errors.invalid": "Zeitangabe nicht erkannt. Verwende 8, 1,5, 1:30, 90m oder 1h 30m.",
|
|
978
|
+
"staff.timesheets.my.duration.errors.out_of_range": "Maximal 24 h pro Tag. Verwende 1:30 oder 90m für kürzere Eintragungen.",
|
|
979
|
+
"staff.timesheets.my.duration.hint": "Stunden (8 oder 1,5), h:mm (1:30) oder Minuten (90m) eingeben. Max. 24 h pro Tag.",
|
|
974
980
|
"staff.timesheets.my.durationPlaceholder": "0",
|
|
975
981
|
"staff.timesheets.my.errors.load": "Failed to load timesheets.",
|
|
976
982
|
"staff.timesheets.my.errors.save": "Failed to save timesheets.",
|
|
@@ -967,10 +967,16 @@
|
|
|
967
967
|
"staff.timesheets.my.calendar.prevMonth": "Previous month",
|
|
968
968
|
"staff.timesheets.my.calendar.thisWeek": "This week",
|
|
969
969
|
"staff.timesheets.my.confirm_save.body": "Your timesheet entries will be saved.",
|
|
970
|
+
"staff.timesheets.my.confirm_save.summary": "{count} cell(s), {total}h in total.",
|
|
970
971
|
"staff.timesheets.my.confirm_save.title": "Save changes?",
|
|
971
972
|
"staff.timesheets.my.createProfile": "Create My Profile",
|
|
972
973
|
"staff.timesheets.my.daily_average": "Daily Average",
|
|
973
974
|
"staff.timesheets.my.daily_total": "Daily Total",
|
|
975
|
+
"staff.timesheets.my.duration.blocked": "Fix the highlighted durations to save",
|
|
976
|
+
"staff.timesheets.my.duration.cellLabel": "Duration for {project} on {date}",
|
|
977
|
+
"staff.timesheets.my.duration.errors.invalid": "Unrecognised duration. Use 8, 1.5, 1:30, 90m or 1h 30m.",
|
|
978
|
+
"staff.timesheets.my.duration.errors.out_of_range": "Maximum 24h per day. Use 1:30 or 90m for shorter entries.",
|
|
979
|
+
"staff.timesheets.my.duration.hint": "Enter hours (8 or 1.5), h:mm (1:30) or minutes (90m). Max 24h per day.",
|
|
974
980
|
"staff.timesheets.my.durationPlaceholder": "0",
|
|
975
981
|
"staff.timesheets.my.errors.load": "Failed to load timesheets.",
|
|
976
982
|
"staff.timesheets.my.errors.save": "Failed to save timesheets.",
|
|
@@ -967,10 +967,16 @@
|
|
|
967
967
|
"staff.timesheets.my.calendar.prevMonth": "Previous month",
|
|
968
968
|
"staff.timesheets.my.calendar.thisWeek": "This week",
|
|
969
969
|
"staff.timesheets.my.confirm_save.body": "Your timesheet entries will be saved.",
|
|
970
|
+
"staff.timesheets.my.confirm_save.summary": "{count} celda(s), {total} h en total.",
|
|
970
971
|
"staff.timesheets.my.confirm_save.title": "Save changes?",
|
|
971
972
|
"staff.timesheets.my.createProfile": "Create Team Member Profile",
|
|
972
973
|
"staff.timesheets.my.daily_average": "Daily Average",
|
|
973
974
|
"staff.timesheets.my.daily_total": "Daily Total",
|
|
975
|
+
"staff.timesheets.my.duration.blocked": "Corrige las duraciones marcadas para guardar",
|
|
976
|
+
"staff.timesheets.my.duration.cellLabel": "Duración de {project} el {date}",
|
|
977
|
+
"staff.timesheets.my.duration.errors.invalid": "Duración no reconocida. Usa 8, 1,5, 1:30, 90m o 1h 30m.",
|
|
978
|
+
"staff.timesheets.my.duration.errors.out_of_range": "Máximo 24 h por día. Usa 1:30 o 90m para entradas más cortas.",
|
|
979
|
+
"staff.timesheets.my.duration.hint": "Introduce horas (8 o 1,5), h:mm (1:30) o minutos (90m). Máximo 24 h por día.",
|
|
974
980
|
"staff.timesheets.my.durationPlaceholder": "0",
|
|
975
981
|
"staff.timesheets.my.errors.load": "Failed to load timesheets.",
|
|
976
982
|
"staff.timesheets.my.errors.save": "Failed to save timesheets.",
|
|
@@ -967,10 +967,16 @@
|
|
|
967
967
|
"staff.timesheets.my.calendar.prevMonth": "이전 달",
|
|
968
968
|
"staff.timesheets.my.calendar.thisWeek": "이번 주",
|
|
969
969
|
"staff.timesheets.my.confirm_save.body": "근무 시간표 항목이 저장됩니다.",
|
|
970
|
+
"staff.timesheets.my.confirm_save.summary": "{count}개 셀, 총 {total}시간.",
|
|
970
971
|
"staff.timesheets.my.confirm_save.title": "변경 사항을 저장하시겠습니까?",
|
|
971
972
|
"staff.timesheets.my.createProfile": "내 프로필 만들기",
|
|
972
973
|
"staff.timesheets.my.daily_average": "일 평균",
|
|
973
974
|
"staff.timesheets.my.daily_total": "일 합계",
|
|
975
|
+
"staff.timesheets.my.duration.blocked": "저장하려면 표시된 시간을 수정하세요",
|
|
976
|
+
"staff.timesheets.my.duration.cellLabel": "{date} {project} 작업 시간",
|
|
977
|
+
"staff.timesheets.my.duration.errors.invalid": "인식할 수 없는 시간입니다. 8, 1.5, 1:30, 90m 또는 1h 30m 형식을 사용하세요.",
|
|
978
|
+
"staff.timesheets.my.duration.errors.out_of_range": "하루 최대 24시간입니다. 더 짧은 입력에는 1:30 또는 90m을 사용하세요.",
|
|
979
|
+
"staff.timesheets.my.duration.hint": "시간(8 또는 1.5), h:mm(1:30) 또는 분(90m)을 입력하세요. 하루 최대 24시간.",
|
|
974
980
|
"staff.timesheets.my.durationPlaceholder": "0",
|
|
975
981
|
"staff.timesheets.my.errors.load": "근무 시간표를 불러오지 못했습니다.",
|
|
976
982
|
"staff.timesheets.my.errors.save": "근무 시간표를 저장하지 못했습니다.",
|
|
@@ -967,10 +967,16 @@
|
|
|
967
967
|
"staff.timesheets.my.calendar.prevMonth": "Poprzedni miesiąc",
|
|
968
968
|
"staff.timesheets.my.calendar.thisWeek": "Ten tydzień",
|
|
969
969
|
"staff.timesheets.my.confirm_save.body": "Twoje wpisy w karcie czasu pracy zostaną zapisane.",
|
|
970
|
+
"staff.timesheets.my.confirm_save.summary": "Zaznaczone komórki: {count}, łącznie {total} h.",
|
|
970
971
|
"staff.timesheets.my.confirm_save.title": "Zapisać zmiany?",
|
|
971
972
|
"staff.timesheets.my.createProfile": "Create Team Member Profile",
|
|
972
973
|
"staff.timesheets.my.daily_average": "Średnia dzienna",
|
|
973
974
|
"staff.timesheets.my.daily_total": "Suma dzienna",
|
|
975
|
+
"staff.timesheets.my.duration.blocked": "Popraw zaznaczone czasy, aby zapisać",
|
|
976
|
+
"staff.timesheets.my.duration.cellLabel": "Czas dla {project} w dniu {date}",
|
|
977
|
+
"staff.timesheets.my.duration.errors.invalid": "Nierozpoznany czas. Użyj 8, 1,5, 1:30, 90m lub 1h 30m.",
|
|
978
|
+
"staff.timesheets.my.duration.errors.out_of_range": "Maksymalnie 24 h na dzień. Dla krótszych wpisów użyj 1:30 lub 90m.",
|
|
979
|
+
"staff.timesheets.my.duration.hint": "Podaj godziny (8 lub 1,5), h:mm (1:30) albo minuty (90m). Maks. 24 h na dzień.",
|
|
974
980
|
"staff.timesheets.my.durationPlaceholder": "0",
|
|
975
981
|
"staff.timesheets.my.errors.load": "Nie udało się załadować kart czasu pracy.",
|
|
976
982
|
"staff.timesheets.my.errors.save": "Nie udało się zapisać kart czasu pracy.",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
const MINUTES_PER_HOUR = 60
|
|
2
|
+
|
|
3
|
+
export const MAX_DURATION_MINUTES = 24 * MINUTES_PER_HOUR
|
|
4
|
+
|
|
5
|
+
export type DurationParseError = 'invalid' | 'out_of_range'
|
|
6
|
+
|
|
7
|
+
export type DurationParseResult =
|
|
8
|
+
| { ok: true; minutes: number }
|
|
9
|
+
| { ok: false; reason: DurationParseError }
|
|
10
|
+
|
|
11
|
+
const NUMBER_SOURCE = '(?:\\d+(?:[.,]\\d*)?|[.,]\\d+)'
|
|
12
|
+
|
|
13
|
+
const CLOCK_PATTERN = /^(\d+):([0-5]?\d)$/
|
|
14
|
+
const DECIMAL_HOURS_PATTERN = new RegExp(`^${NUMBER_SOURCE}$`)
|
|
15
|
+
const UNIT_PATTERN = new RegExp(`^(?:(${NUMBER_SOURCE})\\s*h)?\\s*(?:(${NUMBER_SOURCE})\\s*m(?:in)?)?$`)
|
|
16
|
+
|
|
17
|
+
function toNumber(raw: string): number {
|
|
18
|
+
return Number.parseFloat(raw.replace(',', '.'))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function resolveMinutes(rawMinutes: number): DurationParseResult {
|
|
22
|
+
if (!Number.isFinite(rawMinutes)) return { ok: false, reason: 'invalid' }
|
|
23
|
+
const minutes = Math.round(rawMinutes)
|
|
24
|
+
if (minutes < 0) return { ok: false, reason: 'invalid' }
|
|
25
|
+
if (minutes > MAX_DURATION_MINUTES) return { ok: false, reason: 'out_of_range' }
|
|
26
|
+
return { ok: true, minutes }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseDurationInput(value: string): DurationParseResult {
|
|
30
|
+
const normalized = value.trim().toLowerCase()
|
|
31
|
+
if (!normalized) return { ok: true, minutes: 0 }
|
|
32
|
+
|
|
33
|
+
const clock = CLOCK_PATTERN.exec(normalized)
|
|
34
|
+
if (clock) {
|
|
35
|
+
return resolveMinutes(toNumber(clock[1]) * MINUTES_PER_HOUR + toNumber(clock[2]))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (DECIMAL_HOURS_PATTERN.test(normalized)) {
|
|
39
|
+
return resolveMinutes(toNumber(normalized) * MINUTES_PER_HOUR)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const units = UNIT_PATTERN.exec(normalized)
|
|
43
|
+
if (units && (units[1] !== undefined || units[2] !== undefined)) {
|
|
44
|
+
const hours = units[1] === undefined ? 0 : toNumber(units[1])
|
|
45
|
+
const minutes = units[2] === undefined ? 0 : toNumber(units[2])
|
|
46
|
+
return resolveMinutes(hours * MINUTES_PER_HOUR + minutes)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return { ok: false, reason: 'invalid' }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatMinutesAsDecimal(minutes: number): string {
|
|
53
|
+
if (minutes === 0) return ''
|
|
54
|
+
const hours = minutes / MINUTES_PER_HOUR
|
|
55
|
+
return hours % 1 === 0 ? String(hours) : hours.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
|
|
56
|
+
}
|