@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.
@@ -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
- // Only allow digits, dots, and commas (decimal separators)
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] = sanitized
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 minutes = decimalToMinutes(text)
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">{minutesToDecimal(grandTotal) || '0'}</p>
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">{minutesToDecimal(Math.round(dailyAverage)) || '0'}</p>
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
- {hasChanges && (
739
- <span className="text-xs text-amber-600 font-medium">
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
- <input
927
+ <InlineInput
848
928
  type="text"
849
- inputMode="decimal"
850
- className={`mx-auto block rounded border text-center tabular-nums transition-colors
851
- ${viewMode === 'weekly' ? 'w-12 px-1 py-0.5 text-xs' : 'w-8 px-0 py-1 text-[10px]'}
852
- ${isDirty ? 'border-amber-400 bg-amber-50' : 'border-muted-foreground/20 bg-transparent'}
853
- ${cellMinutes > 0 ? 'font-semibold' : 'text-muted-foreground'}
854
- hover:border-muted-foreground/40 focus:border-primary focus:bg-background focus:outline-none`}
855
- value={rawText[project.id]?.[dateKey] ?? minutesToDecimal(cellMinutes)}
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
- {minutesToDecimal(getRowTotal(project.id)) || '0'}
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 ? '-' : (minutesToDecimal(dayMinutes) || '-')}
979
+ {weekend ? '-' : (formatMinutesAsDecimal(dayMinutes) || '-')}
892
980
  </td>
893
981
  )
894
982
  })}
895
- <td className="px-3 py-2 text-right tabular-nums font-semibold">{minutesToDecimal(grandTotal) || '0'}</td>
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
+ }