@open-mercato/core 0.10.1-develop.7922.1.417ebe275c → 0.10.1-develop.7925.1.ec40f75262
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/catalog/backend/catalog/products/create/page.js +16 -4
- package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
- package/dist/modules/catalog/components/products/ProductMediaManager.js +5 -2
- package/dist/modules/catalog/components/products/ProductMediaManager.js.map +2 -2
- package/dist/modules/entities/api/records.js +18 -2
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/staff/api/timesheets/time-entries/route.js +65 -5
- package/dist/modules/staff/api/timesheets/time-entries/route.js.map +2 -2
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.js +49 -25
- package/dist/modules/staff/backend/staff/time-tracking/entries/page.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js +13 -6
- package/dist/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.js.map +2 -2
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js +23 -0
- package/dist/modules/staff/lib/time-tracking-ui/timeEntryListData.js.map +2 -2
- package/dist/modules/staff/lib/timesheets/timeEntryTotals.js +149 -0
- package/dist/modules/staff/lib/timesheets/timeEntryTotals.js.map +7 -0
- package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js +24 -0
- package/dist/modules/staff/widgets/injection/time-entries-summary-footer/widget.js.map +7 -0
- package/dist/modules/staff/widgets/injection-table.js +4 -0
- package/dist/modules/staff/widgets/injection-table.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/backend/catalog/products/create/page.tsx +14 -1
- package/src/modules/catalog/components/products/ProductMediaManager.tsx +5 -1
- package/src/modules/entities/api/records.ts +18 -2
- package/src/modules/entities/i18n/de.json +1 -0
- package/src/modules/entities/i18n/en.json +1 -0
- package/src/modules/entities/i18n/es.json +1 -0
- package/src/modules/entities/i18n/ko.json +1 -0
- package/src/modules/entities/i18n/pl.json +1 -0
- package/src/modules/staff/AGENTS.md +1 -0
- package/src/modules/staff/api/timesheets/time-entries/route.ts +77 -4
- package/src/modules/staff/backend/staff/time-tracking/entries/page.tsx +29 -7
- package/src/modules/staff/i18n/de.json +1 -0
- package/src/modules/staff/i18n/en.json +1 -0
- package/src/modules/staff/i18n/es.json +1 -0
- package/src/modules/staff/i18n/ko.json +1 -0
- package/src/modules/staff/i18n/pl.json +1 -0
- package/src/modules/staff/lib/time-tracking-ui/TimeEntriesSummaryFooter.tsx +25 -8
- package/src/modules/staff/lib/time-tracking-ui/timeEntryListData.ts +30 -0
- package/src/modules/staff/lib/timesheets/timeEntryTotals.ts +271 -0
- package/src/modules/staff/widgets/injection/time-entries-summary-footer/widget.tsx +32 -0
- package/src/modules/staff/widgets/injection-table.ts +4 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/staff/lib/time-tracking-ui/timeEntryListData.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * The reading half of screen 10 \u2014 everything the entries list needs that is not\n * React. Kept out of the page so the rules the mockup's notes describe can be\n * tested as arithmetic rather than through a rendered table.\n *\n * Three of those rules live here:\n *\n * 1. **Money is never summed across currencies.** `summarizeTimeEntries`\n * returns one subtotal PER currency instead of a single number. A tenant\n * billing one client in PLN and another in EUR would otherwise read a\n * footer total that is arithmetically valid and financially meaningless.\n * The renderer decides between \"one currency \u2192 show it\" and \"several \u2192\n * show them apart\", but it can never be handed a pre-added figure.\n * 2. **A non-billable entry has no cost, not a zero cost.** The list route\n * already answers `null` for it (screen 10 note 5); nothing here turns that\n * into `0`, and the totals skip it rather than adding nothing to a currency\n * that would then appear in the footer.\n * 3. **The lock and the copy-day refusal are read off the response body, not\n * off the status code.** `409` covers an optimistic-lock conflict, a locked\n * entry and a non-empty copy-day target, and each one needs a different\n * sentence in front of the user.\n */\n\nimport {\n clockFromApiValue,\n dateFromApiValue,\n readRowBoolean,\n readRowNumber,\n readRowString,\n type ApiRow,\n} from './timeEntryDialogState'\n\n/** The shared refusal code every write path answers when an entry is frozen in a report. */\nexport const TIME_ENTRY_LOCKED_CODE = 'time_entry_locked'\n\n/** The refusal a second \"copy yesterday\" click gets instead of a doubled day. */\nexport const COPY_DAY_TARGET_NOT_EMPTY_CODE = 'copy_day_target_not_empty'\n\nexport type TimeEntryListRow = {\n id: string\n date: string\n taskId: string | null\n taskTitle: string | null\n timeProjectId: string | null\n projectLabel: string | null\n description: string | null\n startText: string\n endText: string\n durationMinutes: number\n roundedMinutes: number | null\n isBillable: boolean\n cost: number | null\n currencyCode: string | null\n rateOverrideAmount: number | null\n isLocked: boolean\n lockedReportId: string | null\n updatedAt: string | null\n tagIds: string[]\n}\n\nexport type TimeEntryDirectory = {\n taskTitles: ReadonlyMap<string, string>\n projectLabels: ReadonlyMap<string, string>\n}\n\nexport const EMPTY_TIME_ENTRY_DIRECTORY: TimeEntryDirectory = {\n taskTitles: new Map<string, string>(),\n projectLabels: new Map<string, string>(),\n}\n\nfunction readTagIds(row: ApiRow): string[] {\n const tags = Array.isArray(row.tags) ? row.tags : []\n return tags\n .map((tag) => (tag && typeof tag === 'object' ? readRowString(tag as ApiRow, 'id') : null))\n .filter((value): value is string => value !== null)\n}\n\nexport function toTimeEntryListRow(\n row: ApiRow,\n directory: TimeEntryDirectory = EMPTY_TIME_ENTRY_DIRECTORY,\n): TimeEntryListRow | null {\n const id = readRowString(row, 'id')\n if (!id) return null\n const taskId = readRowString(row, 'task_id', 'taskId')\n const timeProjectId = readRowString(row, 'time_project_id', 'timeProjectId')\n return {\n id,\n date: dateFromApiValue(readRowString(row, 'date')),\n taskId,\n taskTitle: taskId ? directory.taskTitles.get(taskId) ?? null : null,\n timeProjectId,\n projectLabel: timeProjectId ? directory.projectLabels.get(timeProjectId) ?? null : null,\n description: readRowString(row, 'description', 'notes'),\n startText: clockFromApiValue(readRowString(row, 'started_at', 'startedAt')),\n endText: clockFromApiValue(readRowString(row, 'ended_at', 'endedAt')),\n durationMinutes: readRowNumber(row, 'duration_minutes', 'durationMinutes') ?? 0,\n roundedMinutes: readRowNumber(row, 'rounded_minutes', 'roundedMinutes'),\n isBillable: readRowBoolean(row, true, 'is_billable', 'isBillable'),\n cost: readRowNumber(row, 'cost'),\n currencyCode: readRowString(row, 'currencyCode', 'rate_currency_code', 'rateCurrencyCode'),\n rateOverrideAmount: readRowNumber(row, 'rate_override_amount', 'rateOverrideAmount'),\n isLocked: readRowBoolean(row, false, 'isLocked', 'is_locked'),\n lockedReportId: readRowString(row, 'lockedReportId', 'locked_report_id'),\n updatedAt: readRowString(row, 'updated_at', 'updatedAt'),\n tagIds: readTagIds(row),\n }\n}\n\nexport type MoneySubtotal = {\n currencyCode: string | null\n amount: number\n}\n\nexport type TimeEntriesSummary = {\n visibleCount: number\n totalMinutes: number\n /** One entry per currency present among the priced rows; never pre-added. */\n money: MoneySubtotal[]\n}\n\n/**\n * The mockup's `Razem (5 z 23 wpis\u00F3w) \u00B7 15:15 \u00B7 4 315,00 PLN`.\n *\n * The time total is the sum of the durations the table SHOWS, so the footer\n * always reconciles against the column above it; cost is the server's `cost`,\n * which is derived from rounded minutes and is therefore not a function of the\n * displayed duration.\n */\nexport function summarizeTimeEntries(rows: readonly TimeEntryListRow[]): TimeEntriesSummary {\n const byCurrency = new Map<string, MoneySubtotal>()\n let totalMinutes = 0\n for (const row of rows) {\n totalMinutes += Number.isFinite(row.durationMinutes) ? row.durationMinutes : 0\n if (row.cost === null || !Number.isFinite(row.cost)) continue\n const key = row.currencyCode ?? ''\n const current = byCurrency.get(key)\n if (current) current.amount += row.cost\n else byCurrency.set(key, { currencyCode: row.currencyCode ?? null, amount: row.cost })\n }\n return {\n visibleCount: rows.length,\n totalMinutes,\n money: Array.from(byCurrency.values()),\n }\n}\n\ntype ErrorBag = Record<string, unknown>\n\n/**\n * `raiseCrudError` spreads the response body onto the thrown Error, but a\n * caller that re-wraps it can leave the payload one level down. Both shapes are\n * read so a lock refusal never degrades into \"something went wrong\".\n */\nfunction errorPayloads(error: unknown): ErrorBag[] {\n if (!error || typeof error !== 'object') return []\n const bags: ErrorBag[] = [error as ErrorBag]\n const nested = (error as { body?: unknown }).body\n if (nested && typeof nested === 'object') bags.push(nested as ErrorBag)\n return bags\n}\n\nexport function readErrorCode(error: unknown): string | null {\n for (const bag of errorPayloads(error)) {\n if (typeof bag.code === 'string' && bag.code.length > 0) return bag.code\n }\n return null\n}\n\nexport function readErrorStatus(error: unknown): number | null {\n for (const bag of errorPayloads(error)) {\n if (typeof bag.status === 'number' && Number.isFinite(bag.status)) return bag.status\n }\n return null\n}\n\n/**\n * The reason the server gave for a refused write \u2014 already translated, e.g.\n * \"This date is outside your assignment to this project.\" \u2014 or the caller's\n * fallback when the failure carried none. Pass the same fallback as the\n * request's `errorMessage`, so a body without a reason never surfaces as\n * `Request failed (403)`.\n */\nexport function readErrorMessage(error: unknown, fallback: string): string {\n if (error instanceof Error && error.message.trim().length > 0) return error.message\n return fallback\n}\n\n/**\n * The payload rows a refused bulk save names in `errors[].index`, so the grid\n * can mark the cells that caused it instead of failing the batch opaquely.\n */\nexport function readRejectedRowIndexes(error: unknown): number[] {\n const indexes = new Set<number>()\n for (const bag of errorPayloads(error)) {\n if (!Array.isArray(bag.errors)) continue\n for (const item of bag.errors) {\n const index = (item as { index?: unknown } | null)?.index\n if (typeof index === 'number' && Number.isInteger(index) && index >= 0) indexes.add(index)\n }\n }\n return Array.from(indexes)\n}\n\n/**\n * True for the `409 time_entry_locked` every write path answers for an entry a\n * closed report has frozen. `entry_locked` is accepted as well so a route that\n * spells the code without its resource prefix still reads as a lock rather than\n * as a generic failure.\n */\nexport function isEntryLockedError(error: unknown): boolean {\n const code = readErrorCode(error)\n return code === TIME_ENTRY_LOCKED_CODE || code === 'entry_locked'\n}\n\nexport type CopyDayTargetConflict = {\n toDate: string | null\n existingEntryCount: number\n}\n\n/**\n * The `409 copy_day_target_not_empty` that names what is already on the target\n * day. Returning the count is the whole point: the user has to be told what\n * they would be adding to before they are offered `allowDuplicates`.\n */\nexport function readCopyDayTargetConflict(error: unknown): CopyDayTargetConflict | null {\n if (readErrorCode(error) !== COPY_DAY_TARGET_NOT_EMPTY_CODE) return null\n for (const bag of errorPayloads(error)) {\n if (bag.code !== COPY_DAY_TARGET_NOT_EMPTY_CODE) continue\n const count = bag.existingEntryCount\n const ids = Array.isArray(bag.existingEntryIds) ? bag.existingEntryIds.length : 0\n return {\n toDate: typeof bag.toDate === 'string' ? bag.toDate : null,\n existingEntryCount: typeof count === 'number' && Number.isFinite(count) ? count : ids,\n }\n }\n return { toDate: null, existingEntryCount: 0 }\n}\n\nexport type IsoDateRange = {\n from: string\n to: string\n}\n\nfunction toIsoDay(date: Date): string {\n const year = date.getFullYear()\n const month = String(date.getMonth() + 1).padStart(2, '0')\n const day = String(date.getDate()).padStart(2, '0')\n return `${year}-${month}-${day}`\n}\n\n/**\n * Monday-to-Sunday, matching the mockup's \"Okres: ten tydzie\u0144\" default. The\n * week is computed in local time because a time entry is addressed by its\n * calendar day, not by an instant.\n */\nexport function currentWeekRange(now: Date = new Date()): IsoDateRange {\n const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n const weekday = (start.getDay() + 6) % 7\n start.setDate(start.getDate() - weekday)\n const end = new Date(start)\n end.setDate(end.getDate() + 6)\n return { from: toIsoDay(start), to: toIsoDay(end) }\n}\n\n/**\n * `pon 20 lip` \u2014 the weekday the mockup leads each row with. The locale is left\n * to the runtime by default, exactly as `formatDate`/`formatCurrency` do, so a\n * date on this page reads the same way as a date anywhere else in the backoffice.\n */\nexport function formatEntryDay(isoDate: string, locale?: string): string {\n if (!isoDate) return ''\n const parsed = new Date(`${isoDate}T00:00:00`)\n if (Number.isNaN(parsed.getTime())) return isoDate\n try {\n return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(parsed)\n } catch {\n return isoDate\n }\n}\n\n/** `09:00 \u2013 11:30`, or an em-dash when the entry carries no clock times. */\nexport function formatEntryClockRange(row: Pick<TimeEntryListRow, 'startText' | 'endText'>): string | null {\n if (!row.startText && !row.endText) return null\n if (row.startText && row.endText) return `${row.startText} \u2013 ${row.endText}`\n return row.startText || row.endText\n}\n\nexport function collectDirectoryIds(rows: readonly TimeEntryListRow[]): {\n taskIds: string[]\n projectIds: string[]\n} {\n const taskIds = new Set<string>()\n const projectIds = new Set<string>()\n for (const row of rows) {\n if (row.taskId) taskIds.add(row.taskId)\n if (row.timeProjectId) projectIds.add(row.timeProjectId)\n }\n return { taskIds: Array.from(taskIds), projectIds: Array.from(projectIds) }\n}\n"],
|
|
5
|
-
"mappings": "AAuBA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;
|
|
4
|
+
"sourcesContent": ["/**\n * The reading half of screen 10 \u2014 everything the entries list needs that is not\n * React. Kept out of the page so the rules the mockup's notes describe can be\n * tested as arithmetic rather than through a rendered table.\n *\n * Three of those rules live here:\n *\n * 1. **Money is never summed across currencies.** `summarizeTimeEntries`\n * returns one subtotal PER currency instead of a single number. A tenant\n * billing one client in PLN and another in EUR would otherwise read a\n * footer total that is arithmetically valid and financially meaningless.\n * The renderer decides between \"one currency \u2192 show it\" and \"several \u2192\n * show them apart\", but it can never be handed a pre-added figure.\n * 2. **A non-billable entry has no cost, not a zero cost.** The list route\n * already answers `null` for it (screen 10 note 5); nothing here turns that\n * into `0`, and the totals skip it rather than adding nothing to a currency\n * that would then appear in the footer.\n * 3. **The lock and the copy-day refusal are read off the response body, not\n * off the status code.** `409` covers an optimistic-lock conflict, a locked\n * entry and a non-empty copy-day target, and each one needs a different\n * sentence in front of the user.\n */\n\nimport {\n clockFromApiValue,\n dateFromApiValue,\n readRowBoolean,\n readRowNumber,\n readRowString,\n type ApiRow,\n} from './timeEntryDialogState'\nimport type { TimeEntryTotals } from '../timesheets/timeEntryTotals'\n\n/** The shared refusal code every write path answers when an entry is frozen in a report. */\nexport const TIME_ENTRY_LOCKED_CODE = 'time_entry_locked'\n\n/** The refusal a second \"copy yesterday\" click gets instead of a doubled day. */\nexport const COPY_DAY_TARGET_NOT_EMPTY_CODE = 'copy_day_target_not_empty'\n\nexport type TimeEntryListRow = {\n id: string\n date: string\n taskId: string | null\n taskTitle: string | null\n timeProjectId: string | null\n projectLabel: string | null\n description: string | null\n startText: string\n endText: string\n durationMinutes: number\n roundedMinutes: number | null\n isBillable: boolean\n cost: number | null\n currencyCode: string | null\n rateOverrideAmount: number | null\n isLocked: boolean\n lockedReportId: string | null\n updatedAt: string | null\n tagIds: string[]\n}\n\nexport type TimeEntryDirectory = {\n taskTitles: ReadonlyMap<string, string>\n projectLabels: ReadonlyMap<string, string>\n}\n\nexport const EMPTY_TIME_ENTRY_DIRECTORY: TimeEntryDirectory = {\n taskTitles: new Map<string, string>(),\n projectLabels: new Map<string, string>(),\n}\n\nfunction readTagIds(row: ApiRow): string[] {\n const tags = Array.isArray(row.tags) ? row.tags : []\n return tags\n .map((tag) => (tag && typeof tag === 'object' ? readRowString(tag as ApiRow, 'id') : null))\n .filter((value): value is string => value !== null)\n}\n\nexport function toTimeEntryListRow(\n row: ApiRow,\n directory: TimeEntryDirectory = EMPTY_TIME_ENTRY_DIRECTORY,\n): TimeEntryListRow | null {\n const id = readRowString(row, 'id')\n if (!id) return null\n const taskId = readRowString(row, 'task_id', 'taskId')\n const timeProjectId = readRowString(row, 'time_project_id', 'timeProjectId')\n return {\n id,\n date: dateFromApiValue(readRowString(row, 'date')),\n taskId,\n taskTitle: taskId ? directory.taskTitles.get(taskId) ?? null : null,\n timeProjectId,\n projectLabel: timeProjectId ? directory.projectLabels.get(timeProjectId) ?? null : null,\n description: readRowString(row, 'description', 'notes'),\n startText: clockFromApiValue(readRowString(row, 'started_at', 'startedAt')),\n endText: clockFromApiValue(readRowString(row, 'ended_at', 'endedAt')),\n durationMinutes: readRowNumber(row, 'duration_minutes', 'durationMinutes') ?? 0,\n roundedMinutes: readRowNumber(row, 'rounded_minutes', 'roundedMinutes'),\n isBillable: readRowBoolean(row, true, 'is_billable', 'isBillable'),\n cost: readRowNumber(row, 'cost'),\n currencyCode: readRowString(row, 'currencyCode', 'rate_currency_code', 'rateCurrencyCode'),\n rateOverrideAmount: readRowNumber(row, 'rate_override_amount', 'rateOverrideAmount'),\n isLocked: readRowBoolean(row, false, 'isLocked', 'is_locked'),\n lockedReportId: readRowString(row, 'lockedReportId', 'locked_report_id'),\n updatedAt: readRowString(row, 'updated_at', 'updatedAt'),\n tagIds: readTagIds(row),\n }\n}\n\nexport type MoneySubtotal = {\n currencyCode: string | null\n amount: number\n}\n\nexport type TimeEntriesSummary = {\n visibleCount: number\n totalMinutes: number\n /** One entry per currency present among the priced rows; never pre-added. */\n money: MoneySubtotal[]\n}\n\n/**\n * The mockup's `Razem (5 z 23 wpis\u00F3w) \u00B7 15:15 \u00B7 4 315,00 PLN`.\n *\n * The time total is the sum of the durations the table SHOWS, so the footer\n * always reconciles against the column above it; cost is the server's `cost`,\n * which is derived from rounded minutes and is therefore not a function of the\n * displayed duration.\n */\nexport function summarizeTimeEntries(rows: readonly TimeEntryListRow[]): TimeEntriesSummary {\n const byCurrency = new Map<string, MoneySubtotal>()\n let totalMinutes = 0\n for (const row of rows) {\n totalMinutes += Number.isFinite(row.durationMinutes) ? row.durationMinutes : 0\n if (row.cost === null || !Number.isFinite(row.cost)) continue\n const key = row.currencyCode ?? ''\n const current = byCurrency.get(key)\n if (current) current.amount += row.cost\n else byCurrency.set(key, { currencyCode: row.currencyCode ?? null, amount: row.cost })\n }\n return {\n visibleCount: rows.length,\n totalMinutes,\n money: Array.from(byCurrency.values()),\n }\n}\n\nfunction readFiniteNumber(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) ? value : null\n}\n\n/**\n * Reads the list API's optional `totals` (sent for `includeTotals=true`). Anything\n * malformed reads as `null`, which keeps the footer on the page-only sums rather\n * than showing a whole-set figure it cannot trust.\n */\nexport function readTimeEntryTotals(value: unknown): TimeEntryTotals | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const entryCount = readFiniteNumber(raw.entryCount)\n const durationMinutes = readFiniteNumber(raw.durationMinutes)\n const roundedMinutes = readFiniteNumber(raw.roundedMinutes)\n if (entryCount === null || durationMinutes === null || roundedMinutes === null) return null\n const totals: TimeEntryTotals = { entryCount, durationMinutes, roundedMinutes }\n if (Array.isArray(raw.money)) {\n totals.money = raw.money.flatMap((item) => {\n if (!item || typeof item !== 'object') return []\n const amount = readFiniteNumber((item as Record<string, unknown>).amount)\n if (amount === null) return []\n const code = (item as Record<string, unknown>).currencyCode\n return [{ currencyCode: typeof code === 'string' && code.length > 0 ? code : null, amount }]\n })\n }\n return totals\n}\n\ntype ErrorBag = Record<string, unknown>\n\n/**\n * `raiseCrudError` spreads the response body onto the thrown Error, but a\n * caller that re-wraps it can leave the payload one level down. Both shapes are\n * read so a lock refusal never degrades into \"something went wrong\".\n */\nfunction errorPayloads(error: unknown): ErrorBag[] {\n if (!error || typeof error !== 'object') return []\n const bags: ErrorBag[] = [error as ErrorBag]\n const nested = (error as { body?: unknown }).body\n if (nested && typeof nested === 'object') bags.push(nested as ErrorBag)\n return bags\n}\n\nexport function readErrorCode(error: unknown): string | null {\n for (const bag of errorPayloads(error)) {\n if (typeof bag.code === 'string' && bag.code.length > 0) return bag.code\n }\n return null\n}\n\nexport function readErrorStatus(error: unknown): number | null {\n for (const bag of errorPayloads(error)) {\n if (typeof bag.status === 'number' && Number.isFinite(bag.status)) return bag.status\n }\n return null\n}\n\n/**\n * The reason the server gave for a refused write \u2014 already translated, e.g.\n * \"This date is outside your assignment to this project.\" \u2014 or the caller's\n * fallback when the failure carried none. Pass the same fallback as the\n * request's `errorMessage`, so a body without a reason never surfaces as\n * `Request failed (403)`.\n */\nexport function readErrorMessage(error: unknown, fallback: string): string {\n if (error instanceof Error && error.message.trim().length > 0) return error.message\n return fallback\n}\n\n/**\n * The payload rows a refused bulk save names in `errors[].index`, so the grid\n * can mark the cells that caused it instead of failing the batch opaquely.\n */\nexport function readRejectedRowIndexes(error: unknown): number[] {\n const indexes = new Set<number>()\n for (const bag of errorPayloads(error)) {\n if (!Array.isArray(bag.errors)) continue\n for (const item of bag.errors) {\n const index = (item as { index?: unknown } | null)?.index\n if (typeof index === 'number' && Number.isInteger(index) && index >= 0) indexes.add(index)\n }\n }\n return Array.from(indexes)\n}\n\n/**\n * True for the `409 time_entry_locked` every write path answers for an entry a\n * closed report has frozen. `entry_locked` is accepted as well so a route that\n * spells the code without its resource prefix still reads as a lock rather than\n * as a generic failure.\n */\nexport function isEntryLockedError(error: unknown): boolean {\n const code = readErrorCode(error)\n return code === TIME_ENTRY_LOCKED_CODE || code === 'entry_locked'\n}\n\nexport type CopyDayTargetConflict = {\n toDate: string | null\n existingEntryCount: number\n}\n\n/**\n * The `409 copy_day_target_not_empty` that names what is already on the target\n * day. Returning the count is the whole point: the user has to be told what\n * they would be adding to before they are offered `allowDuplicates`.\n */\nexport function readCopyDayTargetConflict(error: unknown): CopyDayTargetConflict | null {\n if (readErrorCode(error) !== COPY_DAY_TARGET_NOT_EMPTY_CODE) return null\n for (const bag of errorPayloads(error)) {\n if (bag.code !== COPY_DAY_TARGET_NOT_EMPTY_CODE) continue\n const count = bag.existingEntryCount\n const ids = Array.isArray(bag.existingEntryIds) ? bag.existingEntryIds.length : 0\n return {\n toDate: typeof bag.toDate === 'string' ? bag.toDate : null,\n existingEntryCount: typeof count === 'number' && Number.isFinite(count) ? count : ids,\n }\n }\n return { toDate: null, existingEntryCount: 0 }\n}\n\nexport type IsoDateRange = {\n from: string\n to: string\n}\n\nfunction toIsoDay(date: Date): string {\n const year = date.getFullYear()\n const month = String(date.getMonth() + 1).padStart(2, '0')\n const day = String(date.getDate()).padStart(2, '0')\n return `${year}-${month}-${day}`\n}\n\n/**\n * Monday-to-Sunday, matching the mockup's \"Okres: ten tydzie\u0144\" default. The\n * week is computed in local time because a time entry is addressed by its\n * calendar day, not by an instant.\n */\nexport function currentWeekRange(now: Date = new Date()): IsoDateRange {\n const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())\n const weekday = (start.getDay() + 6) % 7\n start.setDate(start.getDate() - weekday)\n const end = new Date(start)\n end.setDate(end.getDate() + 6)\n return { from: toIsoDay(start), to: toIsoDay(end) }\n}\n\n/**\n * `pon 20 lip` \u2014 the weekday the mockup leads each row with. The locale is left\n * to the runtime by default, exactly as `formatDate`/`formatCurrency` do, so a\n * date on this page reads the same way as a date anywhere else in the backoffice.\n */\nexport function formatEntryDay(isoDate: string, locale?: string): string {\n if (!isoDate) return ''\n const parsed = new Date(`${isoDate}T00:00:00`)\n if (Number.isNaN(parsed.getTime())) return isoDate\n try {\n return new Intl.DateTimeFormat(locale, { weekday: 'short', day: 'numeric', month: 'short' }).format(parsed)\n } catch {\n return isoDate\n }\n}\n\n/** `09:00 \u2013 11:30`, or an em-dash when the entry carries no clock times. */\nexport function formatEntryClockRange(row: Pick<TimeEntryListRow, 'startText' | 'endText'>): string | null {\n if (!row.startText && !row.endText) return null\n if (row.startText && row.endText) return `${row.startText} \u2013 ${row.endText}`\n return row.startText || row.endText\n}\n\nexport function collectDirectoryIds(rows: readonly TimeEntryListRow[]): {\n taskIds: string[]\n projectIds: string[]\n} {\n const taskIds = new Set<string>()\n const projectIds = new Set<string>()\n for (const row of rows) {\n if (row.taskId) taskIds.add(row.taskId)\n if (row.timeProjectId) projectIds.add(row.timeProjectId)\n }\n return { taskIds: Array.from(taskIds), projectIds: Array.from(projectIds) }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
import { entryAmount, round2 } from "../time-tracking/cost.js";
|
|
3
|
+
const FILTERABLE_COLUMNS = /* @__PURE__ */ new Set([
|
|
4
|
+
"id",
|
|
5
|
+
"staff_member_id",
|
|
6
|
+
"date",
|
|
7
|
+
"duration_minutes",
|
|
8
|
+
"rounded_minutes",
|
|
9
|
+
"started_at",
|
|
10
|
+
"ended_at",
|
|
11
|
+
"notes",
|
|
12
|
+
"time_project_id",
|
|
13
|
+
"task_id",
|
|
14
|
+
"customer_id",
|
|
15
|
+
"is_billable",
|
|
16
|
+
"locked_report_id"
|
|
17
|
+
]);
|
|
18
|
+
const COMPARISON_OPERATORS = {
|
|
19
|
+
$gte: ">=",
|
|
20
|
+
$lte: "<=",
|
|
21
|
+
$gt: ">",
|
|
22
|
+
$lt: "<"
|
|
23
|
+
};
|
|
24
|
+
function isPlainObject(value) {
|
|
25
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date);
|
|
26
|
+
}
|
|
27
|
+
function compileColumnCondition(eb, column, condition) {
|
|
28
|
+
if (!FILTERABLE_COLUMNS.has(column)) {
|
|
29
|
+
throw new Error(`[internal] time entry totals: unsupported filter column "${column}"`);
|
|
30
|
+
}
|
|
31
|
+
const ref = `e.${column}`;
|
|
32
|
+
if (condition === null) return eb(ref, "is", null);
|
|
33
|
+
if (!isPlainObject(condition)) return eb(ref, "=", condition);
|
|
34
|
+
const parts = [];
|
|
35
|
+
for (const [operator, operand] of Object.entries(condition)) {
|
|
36
|
+
if (operator === "$eq") {
|
|
37
|
+
parts.push(operand === null ? eb(ref, "is", null) : eb(ref, "=", operand));
|
|
38
|
+
} else if (operator === "$ne") {
|
|
39
|
+
parts.push(operand === null ? eb(ref, "is not", null) : eb(ref, "<>", operand));
|
|
40
|
+
} else if (operator === "$in") {
|
|
41
|
+
if (!Array.isArray(operand)) throw new Error("[internal] time entry totals: $in needs an array");
|
|
42
|
+
parts.push(operand.length === 0 ? sql`false` : eb(ref, "in", operand));
|
|
43
|
+
} else if (operator in COMPARISON_OPERATORS) {
|
|
44
|
+
parts.push(eb(ref, COMPARISON_OPERATORS[operator], operand));
|
|
45
|
+
} else if (operator === "$ilike") {
|
|
46
|
+
parts.push(eb(ref, "ilike", operand));
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error(`[internal] time entry totals: unsupported filter operator "${operator}"`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return parts.length === 1 ? parts[0] : eb.and(parts);
|
|
52
|
+
}
|
|
53
|
+
function compileTimeEntryFilters(eb, filters) {
|
|
54
|
+
const parts = [];
|
|
55
|
+
for (const [key, condition] of Object.entries(filters)) {
|
|
56
|
+
if (condition === void 0) continue;
|
|
57
|
+
if (key === "$or" || key === "$and") {
|
|
58
|
+
if (!Array.isArray(condition) || condition.some((branch) => !isPlainObject(branch))) {
|
|
59
|
+
throw new Error(`[internal] time entry totals: ${key} needs an array of filter objects`);
|
|
60
|
+
}
|
|
61
|
+
const branches = condition.map((branch) => compileTimeEntryFilters(eb, branch));
|
|
62
|
+
if (key === "$or") parts.push(branches.length === 0 ? sql`false` : eb.or(branches));
|
|
63
|
+
else parts.push(...branches);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
parts.push(compileColumnCondition(eb, key, condition));
|
|
67
|
+
}
|
|
68
|
+
if (parts.length === 0) return sql`true`;
|
|
69
|
+
return parts.length === 1 ? parts[0] : eb.and(parts);
|
|
70
|
+
}
|
|
71
|
+
function toNumber(value) {
|
|
72
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
73
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
74
|
+
}
|
|
75
|
+
function toNullableNumber(value) {
|
|
76
|
+
if (value === null || value === void 0) return null;
|
|
77
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
78
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
79
|
+
}
|
|
80
|
+
function baseQuery(db, filters, scope) {
|
|
81
|
+
const scoped = db.selectFrom("staff_time_entries as e").where("e.tenant_id", "=", scope.tenantId).where("e.organization_id", "in", scope.organizationIds);
|
|
82
|
+
const live = scope.includeDeleted ? scoped : scoped.where("e.deleted_at", "is", null);
|
|
83
|
+
return live.where((eb) => compileTimeEntryFilters(eb, filters));
|
|
84
|
+
}
|
|
85
|
+
function buildTimeEntryTotalsQuery(db, filters, scope) {
|
|
86
|
+
return baseQuery(db, filters, scope).select([
|
|
87
|
+
sql`count(*)`.as("entry_count"),
|
|
88
|
+
sql`coalesce(sum(e.duration_minutes), 0)`.as("duration_minutes"),
|
|
89
|
+
sql`coalesce(sum(e.rounded_minutes), 0)`.as("rounded_minutes")
|
|
90
|
+
]);
|
|
91
|
+
}
|
|
92
|
+
function buildTimeEntryMoneyTotalsQuery(db, filters, scope) {
|
|
93
|
+
return baseQuery(db, filters, scope).leftJoin(
|
|
94
|
+
"staff_time_projects as p",
|
|
95
|
+
(join) => join.onRef("p.id", "=", "e.time_project_id").onRef("p.tenant_id", "=", "e.tenant_id").onRef("p.organization_id", "=", "e.organization_id").on("p.deleted_at", "is", null)
|
|
96
|
+
).where("e.is_billable", "=", true).where(sql`coalesce(e.rate_override_amount, p.hourly_rate) is not null`).select([
|
|
97
|
+
sql`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`.as("currency_code"),
|
|
98
|
+
sql`coalesce(e.rounded_minutes, 0)`.as("rounded_minutes"),
|
|
99
|
+
sql`e.rate_override_amount`.as("rate_override_amount"),
|
|
100
|
+
sql`p.hourly_rate`.as("hourly_rate"),
|
|
101
|
+
sql`count(*)`.as("entry_count")
|
|
102
|
+
]).groupBy([
|
|
103
|
+
sql`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`,
|
|
104
|
+
sql`coalesce(e.rounded_minutes, 0)`,
|
|
105
|
+
sql`e.rate_override_amount`,
|
|
106
|
+
sql`p.hourly_rate`
|
|
107
|
+
]).orderBy(sql`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`);
|
|
108
|
+
}
|
|
109
|
+
function sumTimeEntryMoneyGroups(groups) {
|
|
110
|
+
const centsByCurrency = /* @__PURE__ */ new Map();
|
|
111
|
+
for (const group of groups) {
|
|
112
|
+
const amount = entryAmount(
|
|
113
|
+
{
|
|
114
|
+
isBillable: true,
|
|
115
|
+
roundedMinutes: toNumber(group.rounded_minutes),
|
|
116
|
+
rateOverrideAmount: toNullableNumber(group.rate_override_amount)
|
|
117
|
+
},
|
|
118
|
+
{ hourlyRate: toNullableNumber(group.hourly_rate) }
|
|
119
|
+
);
|
|
120
|
+
if (amount === null) continue;
|
|
121
|
+
const currencyCode = typeof group.currency_code === "string" && group.currency_code.length > 0 ? group.currency_code : null;
|
|
122
|
+
const cents = Math.round(round2(amount) * 100) * toNumber(group.entry_count);
|
|
123
|
+
centsByCurrency.set(currencyCode, (centsByCurrency.get(currencyCode) ?? 0) + cents);
|
|
124
|
+
}
|
|
125
|
+
return Array.from(centsByCurrency, ([currencyCode, cents]) => ({ currencyCode, amount: round2(cents / 100) }));
|
|
126
|
+
}
|
|
127
|
+
async function computeTimeEntryTotals(db, filters, scope) {
|
|
128
|
+
const typedDb = db;
|
|
129
|
+
if (scope.organizationIds.length === 0) {
|
|
130
|
+
return { entryCount: 0, durationMinutes: 0, roundedMinutes: 0, ...scope.canSeeRates ? { money: [] } : {} };
|
|
131
|
+
}
|
|
132
|
+
const row = await buildTimeEntryTotalsQuery(typedDb, filters, scope).executeTakeFirst();
|
|
133
|
+
const totals = {
|
|
134
|
+
entryCount: toNumber(row?.entry_count),
|
|
135
|
+
durationMinutes: toNumber(row?.duration_minutes),
|
|
136
|
+
roundedMinutes: toNumber(row?.rounded_minutes)
|
|
137
|
+
};
|
|
138
|
+
if (!scope.canSeeRates) return totals;
|
|
139
|
+
totals.money = sumTimeEntryMoneyGroups(await buildTimeEntryMoneyTotalsQuery(typedDb, filters, scope).execute());
|
|
140
|
+
return totals;
|
|
141
|
+
}
|
|
142
|
+
export {
|
|
143
|
+
buildTimeEntryMoneyTotalsQuery,
|
|
144
|
+
buildTimeEntryTotalsQuery,
|
|
145
|
+
compileTimeEntryFilters,
|
|
146
|
+
computeTimeEntryTotals,
|
|
147
|
+
sumTimeEntryMoneyGroups
|
|
148
|
+
};
|
|
149
|
+
//# sourceMappingURL=timeEntryTotals.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../src/modules/staff/lib/timesheets/timeEntryTotals.ts"],
|
|
4
|
+
"sourcesContent": ["/**\n * Totals for the WHOLE filtered set of time entries, not the page on screen.\n *\n * The list route answers `?includeTotals=true` with one extra aggregate query\n * over the same scoped filters the list itself runs (`buildScopedTimeEntryListFilters`),\n * so the footer can say \"Total for these 300 entries\" instead of silently summing\n * the 50 rows of the current page.\n *\n * The filters are the column-keyed shape the CRUD query engine consumes. Only the\n * operators that shape actually uses are compiled; anything else throws, and the\n * route then omits `totals` rather than answering a different question than the\n * list did.\n *\n * Money is produced by `entryAmount` \u2014 the single place an amount is produced\n * (D-7) \u2014 and summed in cents like `sumAmounts`, so the total always equals the\n * sum of the row `cost` values and the report totals. PostgreSQL only groups the\n * billable, priced entries by currency, rounded minutes and both rates, and\n * counts them; pricing each group in SQL would round exact decimals\n * (0.75 h \u00D7 0.30 = 0.225 \u2192 0.23) where `entryAmount` rounds a float\n * (0.22499\u2026 \u2192 0.22). The currency is the entry's snapshot currency or else the\n * project's; currencies are never added together.\n */\n\nimport { sql, type Expression, type ExpressionBuilder, type Kysely, type SqlBool } from 'kysely'\nimport { entryAmount, round2 } from '../time-tracking/cost'\n\nexport type TimeEntryMoneyTotal = {\n currencyCode: string | null\n amount: number\n}\n\nexport type TimeEntryTotals = {\n entryCount: number\n durationMinutes: number\n roundedMinutes: number\n /** Present only for a caller holding `staff.timesheets.rates.view`. */\n money?: TimeEntryMoneyTotal[]\n}\n\ntype TotalsDb = {\n staff_time_entries: {\n id: string\n tenant_id: string\n organization_id: string\n staff_member_id: string\n date: string\n duration_minutes: number\n rounded_minutes: number | null\n started_at: string | null\n ended_at: string | null\n notes: string | null\n time_project_id: string | null\n task_id: string | null\n customer_id: string | null\n is_billable: boolean\n rate_override_amount: string | null\n rate_currency_code: string | null\n locked_report_id: string | null\n deleted_at: string | null\n }\n staff_time_projects: {\n id: string\n tenant_id: string\n organization_id: string\n hourly_rate: string | null\n currency_code: string | null\n deleted_at: string | null\n }\n}\n\nconst FILTERABLE_COLUMNS = new Set([\n 'id',\n 'staff_member_id',\n 'date',\n 'duration_minutes',\n 'rounded_minutes',\n 'started_at',\n 'ended_at',\n 'notes',\n 'time_project_id',\n 'task_id',\n 'customer_id',\n 'is_billable',\n 'locked_report_id',\n])\n\ntype FilterableColumn = `e.${keyof TotalsDb['staff_time_entries']}`\ntype EntryExpressionBuilder = ExpressionBuilder<{ e: TotalsDb['staff_time_entries'] }, 'e'>\n\nconst COMPARISON_OPERATORS: Record<string, '>=' | '<=' | '>' | '<'> = {\n $gte: '>=',\n $lte: '<=',\n $gt: '>',\n $lt: '<',\n}\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value) && !(value instanceof Date)\n}\n\nfunction compileColumnCondition(\n eb: EntryExpressionBuilder,\n column: string,\n condition: unknown,\n): Expression<SqlBool> {\n if (!FILTERABLE_COLUMNS.has(column)) {\n throw new Error(`[internal] time entry totals: unsupported filter column \"${column}\"`)\n }\n const ref = `e.${column}` as FilterableColumn\n if (condition === null) return eb(ref, 'is', null)\n if (!isPlainObject(condition)) return eb(ref, '=', condition as never)\n const parts: Expression<SqlBool>[] = []\n for (const [operator, operand] of Object.entries(condition)) {\n if (operator === '$eq') {\n parts.push(operand === null ? eb(ref, 'is', null) : eb(ref, '=', operand as never))\n } else if (operator === '$ne') {\n parts.push(operand === null ? eb(ref, 'is not', null) : eb(ref, '<>', operand as never))\n } else if (operator === '$in') {\n if (!Array.isArray(operand)) throw new Error('[internal] time entry totals: $in needs an array')\n parts.push(operand.length === 0 ? sql<SqlBool>`false` : eb(ref, 'in', operand as never[]))\n } else if (operator in COMPARISON_OPERATORS) {\n parts.push(eb(ref, COMPARISON_OPERATORS[operator], operand as never))\n } else if (operator === '$ilike') {\n parts.push(eb(ref, 'ilike', operand as never))\n } else {\n throw new Error(`[internal] time entry totals: unsupported filter operator \"${operator}\"`)\n }\n }\n return parts.length === 1 ? parts[0] : eb.and(parts)\n}\n\nexport function compileTimeEntryFilters(\n eb: EntryExpressionBuilder,\n filters: Record<string, unknown>,\n): Expression<SqlBool> {\n const parts: Expression<SqlBool>[] = []\n for (const [key, condition] of Object.entries(filters)) {\n if (condition === undefined) continue\n if (key === '$or' || key === '$and') {\n if (!Array.isArray(condition) || condition.some((branch) => !isPlainObject(branch))) {\n throw new Error(`[internal] time entry totals: ${key} needs an array of filter objects`)\n }\n const branches = condition.map((branch) => compileTimeEntryFilters(eb, branch as Record<string, unknown>))\n if (key === '$or') parts.push(branches.length === 0 ? sql<SqlBool>`false` : eb.or(branches))\n else parts.push(...branches)\n continue\n }\n parts.push(compileColumnCondition(eb, key, condition))\n }\n if (parts.length === 0) return sql<SqlBool>`true`\n return parts.length === 1 ? parts[0] : eb.and(parts)\n}\n\nexport type TimeEntryTotalsScope = {\n tenantId: string\n /** Organizations the list itself is scoped to; an empty list matches nothing. */\n organizationIds: string[]\n canSeeRates: boolean\n /** Mirrors the list's `withDeleted=true`: soft-deleted entries are counted too. */\n includeDeleted?: boolean\n}\n\nfunction toNumber(value: unknown): number {\n const parsed = typeof value === 'number' ? value : Number(value)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\nfunction toNullableNumber(value: unknown): number | null {\n if (value === null || value === undefined) return null\n const parsed = typeof value === 'number' ? value : Number(value)\n return Number.isFinite(parsed) ? parsed : null\n}\n\nfunction baseQuery(db: Kysely<TotalsDb>, filters: Record<string, unknown>, scope: TimeEntryTotalsScope) {\n const scoped = db\n .selectFrom('staff_time_entries as e')\n .where('e.tenant_id', '=', scope.tenantId)\n .where('e.organization_id', 'in', scope.organizationIds)\n const live = scope.includeDeleted ? scoped : scoped.where('e.deleted_at', 'is', null)\n return live.where((eb) => compileTimeEntryFilters(eb as unknown as EntryExpressionBuilder, filters))\n}\n\nexport function buildTimeEntryTotalsQuery(\n db: Kysely<TotalsDb>,\n filters: Record<string, unknown>,\n scope: TimeEntryTotalsScope,\n) {\n return baseQuery(db, filters, scope).select([\n sql<string>`count(*)`.as('entry_count'),\n sql<string>`coalesce(sum(e.duration_minutes), 0)`.as('duration_minutes'),\n sql<string>`coalesce(sum(e.rounded_minutes), 0)`.as('rounded_minutes'),\n ])\n}\n\nexport function buildTimeEntryMoneyTotalsQuery(\n db: Kysely<TotalsDb>,\n filters: Record<string, unknown>,\n scope: TimeEntryTotalsScope,\n) {\n return baseQuery(db, filters, scope)\n .leftJoin('staff_time_projects as p', (join) =>\n join\n .onRef('p.id', '=', 'e.time_project_id')\n .onRef('p.tenant_id', '=', 'e.tenant_id')\n .onRef('p.organization_id', '=', 'e.organization_id')\n .on('p.deleted_at', 'is', null),\n )\n .where('e.is_billable', '=', true)\n .where(sql<SqlBool>`coalesce(e.rate_override_amount, p.hourly_rate) is not null`)\n .select([\n sql<string | null>`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`.as('currency_code'),\n sql<string>`coalesce(e.rounded_minutes, 0)`.as('rounded_minutes'),\n sql<string | null>`e.rate_override_amount`.as('rate_override_amount'),\n sql<string | null>`p.hourly_rate`.as('hourly_rate'),\n sql<string>`count(*)`.as('entry_count'),\n ])\n .groupBy([\n sql`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`,\n sql`coalesce(e.rounded_minutes, 0)`,\n sql`e.rate_override_amount`,\n sql`p.hourly_rate`,\n ])\n .orderBy(sql`coalesce(nullif(trim(e.rate_currency_code), ''), p.currency_code)`)\n}\n\ntype MoneyGroupRow = {\n currency_code: string | null\n rounded_minutes: unknown\n rate_override_amount: unknown\n hourly_rate: unknown\n entry_count: unknown\n}\n\nexport function sumTimeEntryMoneyGroups(groups: readonly MoneyGroupRow[]): TimeEntryMoneyTotal[] {\n const centsByCurrency = new Map<string | null, number>()\n for (const group of groups) {\n const amount = entryAmount(\n {\n isBillable: true,\n roundedMinutes: toNumber(group.rounded_minutes),\n rateOverrideAmount: toNullableNumber(group.rate_override_amount),\n },\n { hourlyRate: toNullableNumber(group.hourly_rate) },\n )\n if (amount === null) continue\n const currencyCode = typeof group.currency_code === 'string' && group.currency_code.length > 0 ? group.currency_code : null\n const cents = Math.round(round2(amount) * 100) * toNumber(group.entry_count)\n centsByCurrency.set(currencyCode, (centsByCurrency.get(currencyCode) ?? 0) + cents)\n }\n return Array.from(centsByCurrency, ([currencyCode, cents]) => ({ currencyCode, amount: round2(cents / 100) }))\n}\n\nexport async function computeTimeEntryTotals(\n db: Kysely<unknown>,\n filters: Record<string, unknown>,\n scope: TimeEntryTotalsScope,\n): Promise<TimeEntryTotals> {\n const typedDb = db as unknown as Kysely<TotalsDb>\n if (scope.organizationIds.length === 0) {\n return { entryCount: 0, durationMinutes: 0, roundedMinutes: 0, ...(scope.canSeeRates ? { money: [] } : {}) }\n }\n const row = await buildTimeEntryTotalsQuery(typedDb, filters, scope).executeTakeFirst()\n const totals: TimeEntryTotals = {\n entryCount: toNumber(row?.entry_count),\n durationMinutes: toNumber(row?.duration_minutes),\n roundedMinutes: toNumber(row?.rounded_minutes),\n }\n if (!scope.canSeeRates) return totals\n totals.money = sumTimeEntryMoneyGroups(await buildTimeEntryMoneyTotalsQuery(typedDb, filters, scope).execute())\n return totals\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import {
|
|
4
|
+
TimeEntriesSummaryFooter
|
|
5
|
+
} from "../../../lib/time-tracking-ui/TimeEntriesSummaryFooter.js";
|
|
6
|
+
function TimeEntriesSummaryFooterWidget({ context }) {
|
|
7
|
+
const props = context?.entriesSummary;
|
|
8
|
+
if (!props) return null;
|
|
9
|
+
return /* @__PURE__ */ jsx(TimeEntriesSummaryFooter, { ...props });
|
|
10
|
+
}
|
|
11
|
+
const widget = {
|
|
12
|
+
metadata: {
|
|
13
|
+
id: "staff.injection.time-entries-summary-footer",
|
|
14
|
+
title: "Time entries totals",
|
|
15
|
+
description: "Totals for the filtered time entries, rendered inside the entries table footer.",
|
|
16
|
+
features: ["staff.timesheets.view"]
|
|
17
|
+
},
|
|
18
|
+
Widget: TimeEntriesSummaryFooterWidget
|
|
19
|
+
};
|
|
20
|
+
var widget_default = widget;
|
|
21
|
+
export {
|
|
22
|
+
widget_default as default
|
|
23
|
+
};
|
|
24
|
+
//# sourceMappingURL=widget.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../src/modules/staff/widgets/injection/time-entries-summary-footer/widget.tsx"],
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport {\n TimeEntriesSummaryFooter,\n type TimeEntriesSummaryFooterProps,\n} from '../../../lib/time-tracking-ui/TimeEntriesSummaryFooter'\n\nexport type TimeEntriesTableInjectionContext = {\n tableId?: string | null\n title?: string\n entriesSummary?: TimeEntriesSummaryFooterProps\n}\n\nfunction TimeEntriesSummaryFooterWidget({ context }: { context: TimeEntriesTableInjectionContext }) {\n const props = context?.entriesSummary\n if (!props) return null\n return <TimeEntriesSummaryFooter {...props} />\n}\n\nconst widget: InjectionWidgetModule<TimeEntriesTableInjectionContext> = {\n metadata: {\n id: 'staff.injection.time-entries-summary-footer',\n title: 'Time entries totals',\n description: 'Totals for the filtered time entries, rendered inside the entries table footer.',\n features: ['staff.timesheets.view'],\n },\n Widget: TimeEntriesSummaryFooterWidget,\n}\n\nexport default widget\n"],
|
|
5
|
+
"mappings": ";AAkBS;AAdT;AAAA,EACE;AAAA,OAEK;AAQP,SAAS,+BAA+B,EAAE,QAAQ,GAAkD;AAClG,QAAM,QAAQ,SAAS;AACvB,MAAI,CAAC,MAAO,QAAO;AACnB,SAAO,oBAAC,4BAA0B,GAAG,OAAO;AAC9C;AAEA,MAAM,SAAkE;AAAA,EACtE,UAAU;AAAA,IACR,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU,CAAC,uBAAuB;AAAA,EACpC;AAAA,EACA,QAAQ;AACV;AAEA,IAAO,iBAAQ;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -3,6 +3,10 @@ const injectionTable = {
|
|
|
3
3
|
widgetId: "staff.injection.timer-sidebar-indicator",
|
|
4
4
|
priority: 90
|
|
5
5
|
},
|
|
6
|
+
"data-table:staff.time_entries.list:footer": {
|
|
7
|
+
widgetId: "staff.injection.time-entries-summary-footer",
|
|
8
|
+
priority: 100
|
|
9
|
+
},
|
|
6
10
|
"staff.time_tracking.settings:tabs": [
|
|
7
11
|
{
|
|
8
12
|
widgetId: "staff.injection.time-tracking-settings-support",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/staff/widgets/injection-table.ts"],
|
|
4
|
-
"sourcesContent": ["import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'\n\nexport const injectionTable: ModuleInjectionTable = {\n 'backend:sidebar:nav:footer': {\n widgetId: 'staff.injection.timer-sidebar-indicator',\n priority: 90,\n },\n 'staff.time_tracking.settings:tabs': [\n {\n widgetId: 'staff.injection.time-tracking-settings-support',\n kind: 'tab',\n groupId: 'support',\n groupLabel: 'staff.support.settings.title',\n priority: 50,\n },\n ],\n 'portal:dashboard:sections': [\n {\n widgetId: 'staff.injection.portal-support-tickets',\n priority: 20,\n },\n ],\n}\n\nexport default injectionTable\n"],
|
|
5
|
-
"mappings": "AAEO,MAAM,iBAAuC;AAAA,EAClD,8BAA8B;AAAA,IAC5B,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA,qCAAqC;AAAA,IACnC;AAAA,MACE,UAAU;AAAA,MACV,MAAM;AAAA,MACN,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,UAAU;AAAA,IACZ;AAAA,EACF;AAAA,EACA,6BAA6B;AAAA,IAC3B;AAAA,MACE,UAAU;AAAA,MACV,UAAU;AAAA,IACZ;AAAA,EACF;AACF;AAEA,IAAO,0BAAQ;",
|
|
4
|
+
"sourcesContent": ["import type { ModuleInjectionTable } from '@open-mercato/shared/modules/widgets/injection'\n\nexport const injectionTable: ModuleInjectionTable = {\n 'backend:sidebar:nav:footer': {\n widgetId: 'staff.injection.timer-sidebar-indicator',\n priority: 90,\n },\n 'data-table:staff.time_entries.list:footer': {\n widgetId: 'staff.injection.time-entries-summary-footer',\n priority: 100,\n },\n 'staff.time_tracking.settings:tabs': [\n {\n widgetId: 'staff.injection.time-tracking-settings-support',\n kind: 'tab',\n groupId: 'support',\n groupLabel: 'staff.support.settings.title',\n priority: 50,\n },\n ],\n 'portal:dashboard:sections': [\n {\n widgetId: 'staff.injection.portal-support-tickets',\n priority: 20,\n },\n ],\n}\n\nexport default injectionTable\n"],
|
|
5
|
+
"mappings": "AAEO,MAAM,iBAAuC;AAAA,EAClD,8BAA8B;AAAA,IAC5B,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA,6CAA6C;AAAA,IAC3C,UAAU;AAAA,IACV,UAAU;AAAA,EACZ;AAAA,EACA,qCAAqC;AAAA,IACnC;AAAA,MACE,UAAU;AAAA,MACV,MAAM;AAAA,MACN,SAAS;AAAA,MACT,YAAY;AAAA,MACZ,UAAU;AAAA,IACZ;AAAA,EACF;AAAA,EACA,6BAA6B;AAAA,IAC3B;AAAA,MACE,UAAU;AAAA,MACV,UAAU;AAAA,IACZ;AAAA,EACF;AACF;AAEA,IAAO,0BAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.10.1-develop.
|
|
3
|
+
"version": "0.10.1-develop.7925.1.ec40f75262",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -266,16 +266,16 @@
|
|
|
266
266
|
"zod": "^4.6.6"
|
|
267
267
|
},
|
|
268
268
|
"peerDependencies": {
|
|
269
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
270
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
271
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
269
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7925.1.ec40f75262",
|
|
270
|
+
"@open-mercato/shared": "0.10.1-develop.7925.1.ec40f75262",
|
|
271
|
+
"@open-mercato/ui": "0.10.1-develop.7925.1.ec40f75262",
|
|
272
272
|
"react": "^19.0.0",
|
|
273
273
|
"react-dom": "^19.0.0"
|
|
274
274
|
},
|
|
275
275
|
"devDependencies": {
|
|
276
|
-
"@open-mercato/ai-assistant": "0.10.1-develop.
|
|
277
|
-
"@open-mercato/shared": "0.10.1-develop.
|
|
278
|
-
"@open-mercato/ui": "0.10.1-develop.
|
|
276
|
+
"@open-mercato/ai-assistant": "0.10.1-develop.7925.1.ec40f75262",
|
|
277
|
+
"@open-mercato/shared": "0.10.1-develop.7925.1.ec40f75262",
|
|
278
|
+
"@open-mercato/ui": "0.10.1-develop.7925.1.ec40f75262",
|
|
279
279
|
"@testing-library/dom": "^10.4.1",
|
|
280
280
|
"@testing-library/jest-dom": "^7.0.1",
|
|
281
281
|
"@testing-library/react": "^16.3.3",
|
|
@@ -246,6 +246,10 @@ export default function CreateCatalogProductPage() {
|
|
|
246
246
|
}, [fromInboxAction]);
|
|
247
247
|
|
|
248
248
|
const initialValuesRef = React.useRef<ProductFormValues | null>(null);
|
|
249
|
+
const mediaUploadingRef = React.useRef(false);
|
|
250
|
+
const handleMediaUploadingChange = React.useCallback((isUploading: boolean) => {
|
|
251
|
+
mediaUploadingRef.current = isUploading;
|
|
252
|
+
}, []);
|
|
249
253
|
if (!initialValuesRef.current) {
|
|
250
254
|
const initial = createInitialProductFormValues();
|
|
251
255
|
if (inboxDraft?.payload) {
|
|
@@ -349,6 +353,7 @@ export default function CreateCatalogProductPage() {
|
|
|
349
353
|
priceKinds={priceKinds}
|
|
350
354
|
taxRates={taxRates}
|
|
351
355
|
requiredFieldIds={requiredFieldIds}
|
|
356
|
+
onMediaUploadingChange={handleMediaUploadingChange}
|
|
352
357
|
/>
|
|
353
358
|
),
|
|
354
359
|
},
|
|
@@ -374,7 +379,7 @@ export default function CreateCatalogProductPage() {
|
|
|
374
379
|
),
|
|
375
380
|
},
|
|
376
381
|
],
|
|
377
|
-
[priceKinds, taxRates, t],
|
|
382
|
+
[handleMediaUploadingChange, priceKinds, taxRates, t],
|
|
378
383
|
);
|
|
379
384
|
|
|
380
385
|
return (
|
|
@@ -395,6 +400,11 @@ export default function CreateCatalogProductPage() {
|
|
|
395
400
|
submitLabel={t("catalog.products.create.submit", "Create")}
|
|
396
401
|
cancelHref="/backend/catalog/products"
|
|
397
402
|
onSubmit={async (formValues) => {
|
|
403
|
+
if (mediaUploadingRef.current) {
|
|
404
|
+
throw createCrudFormError(
|
|
405
|
+
t("catalog.products.media.uploading", "Uploading…"),
|
|
406
|
+
);
|
|
407
|
+
}
|
|
398
408
|
const title = formValues.title?.trim();
|
|
399
409
|
if (!title) {
|
|
400
410
|
throw createCrudFormError(
|
|
@@ -872,6 +882,7 @@ type ProductBuilderProps = {
|
|
|
872
882
|
priceKinds: PriceKindSummary[];
|
|
873
883
|
taxRates: TaxRateSummary[];
|
|
874
884
|
requiredFieldIds?: ReadonlySet<string>;
|
|
885
|
+
onMediaUploadingChange: (isUploading: boolean) => void;
|
|
875
886
|
};
|
|
876
887
|
|
|
877
888
|
type ProductMetaSectionProps = {
|
|
@@ -1030,6 +1041,7 @@ function ProductBuilder({
|
|
|
1030
1041
|
priceKinds,
|
|
1031
1042
|
taxRates,
|
|
1032
1043
|
requiredFieldIds,
|
|
1044
|
+
onMediaUploadingChange,
|
|
1033
1045
|
}: ProductBuilderProps) {
|
|
1034
1046
|
const t = useT();
|
|
1035
1047
|
const steps = PRODUCT_FORM_STEPS;
|
|
@@ -1452,6 +1464,7 @@ function ProductBuilder({
|
|
|
1452
1464
|
defaultMediaId={values.defaultMediaId ?? null}
|
|
1453
1465
|
onItemsChange={handleMediaItemsChange}
|
|
1454
1466
|
onDefaultChange={handleDefaultMediaChange}
|
|
1467
|
+
onUploadingChange={onMediaUploadingChange}
|
|
1455
1468
|
/>
|
|
1456
1469
|
|
|
1457
1470
|
<ProductDimensionsFields
|
|
@@ -23,6 +23,7 @@ type Props = {
|
|
|
23
23
|
defaultMediaId: string | null
|
|
24
24
|
onItemsChange: (items: ProductMediaItem[]) => void
|
|
25
25
|
onDefaultChange: (attachmentId: string | null) => void
|
|
26
|
+
onUploadingChange?: (isUploading: boolean) => void
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function humanFileSize(size: number): string {
|
|
@@ -44,6 +45,7 @@ export function ProductMediaManager({
|
|
|
44
45
|
defaultMediaId,
|
|
45
46
|
onItemsChange,
|
|
46
47
|
onDefaultChange,
|
|
48
|
+
onUploadingChange,
|
|
47
49
|
}: Props) {
|
|
48
50
|
const t = useT()
|
|
49
51
|
const fileInputRef = React.useRef<HTMLInputElement | null>(null)
|
|
@@ -56,6 +58,7 @@ export function ProductMediaManager({
|
|
|
56
58
|
if (!files || !files.length || !draftRecordId) return
|
|
57
59
|
setError(null)
|
|
58
60
|
setUploading(true)
|
|
61
|
+
onUploadingChange?.(true)
|
|
59
62
|
let nextItems = items
|
|
60
63
|
let defaultId = defaultMediaId
|
|
61
64
|
try {
|
|
@@ -101,12 +104,13 @@ export function ProductMediaManager({
|
|
|
101
104
|
}
|
|
102
105
|
} finally {
|
|
103
106
|
setUploading(false)
|
|
107
|
+
onUploadingChange?.(false)
|
|
104
108
|
if (fileInputRef.current) {
|
|
105
109
|
fileInputRef.current.value = ''
|
|
106
110
|
}
|
|
107
111
|
}
|
|
108
112
|
},
|
|
109
|
-
[defaultMediaId, draftRecordId, entityId, items, onDefaultChange, onItemsChange, t],
|
|
113
|
+
[defaultMediaId, draftRecordId, entityId, items, onDefaultChange, onItemsChange, onUploadingChange, t],
|
|
110
114
|
)
|
|
111
115
|
|
|
112
116
|
const handleRemove = React.useCallback(
|
|
@@ -15,6 +15,7 @@ import { CustomFieldValue } from '../data/entities'
|
|
|
15
15
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
16
16
|
import { enforceCommandOptimisticLockWithGuards } from '@open-mercato/shared/lib/crud/optimistic-lock-command'
|
|
17
17
|
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
18
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
18
19
|
import { assertEntityAclForRequest } from '../lib/entityAcl'
|
|
19
20
|
import { classifyRecordsEntity } from '../lib/entityClassification'
|
|
20
21
|
import { loadCustomFieldKinds } from '../lib/scoped-field-defs'
|
|
@@ -420,6 +421,7 @@ export async function POST(req: Request) {
|
|
|
420
421
|
const { id } = await de.createCustomEntityRecord({
|
|
421
422
|
entityId,
|
|
422
423
|
recordId: normalizedRecordId,
|
|
424
|
+
createOnly: true,
|
|
423
425
|
organizationId: targetOrgId,
|
|
424
426
|
tenantId: auth.tenantId!,
|
|
425
427
|
values: norm,
|
|
@@ -427,7 +429,16 @@ export async function POST(req: Request) {
|
|
|
427
429
|
|
|
428
430
|
return NextResponse.json({ ok: true, item: { entityId, recordId: id } })
|
|
429
431
|
} catch (e) {
|
|
430
|
-
if (isCrudHttpError(e))
|
|
432
|
+
if (isCrudHttpError(e)) {
|
|
433
|
+
if (e.body.code === 'custom_entity_record_exists') {
|
|
434
|
+
const { translate } = await resolveTranslations()
|
|
435
|
+
return NextResponse.json({
|
|
436
|
+
...e.body,
|
|
437
|
+
error: translate('entities.records.errors.alreadyExists', 'A record with this ID already exists. Use PUT to update it.'),
|
|
438
|
+
}, { status: e.status })
|
|
439
|
+
}
|
|
440
|
+
return NextResponse.json(e.body, { status: e.status })
|
|
441
|
+
}
|
|
431
442
|
logger.error('Records POST failed', { err: e })
|
|
432
443
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
|
|
433
444
|
}
|
|
@@ -645,7 +656,7 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
645
656
|
POST: {
|
|
646
657
|
summary: 'Create record',
|
|
647
658
|
description:
|
|
648
|
-
'Creates a record for the given entity. When
|
|
659
|
+
'Creates a record for the given entity. An existing recordId returns 409; use PUT to update a record. When recordId is omitted or not a UUID the data engine will generate one automatically.',
|
|
649
660
|
requestBody: {
|
|
650
661
|
contentType: 'application/json',
|
|
651
662
|
schema: postBodySchema,
|
|
@@ -656,6 +667,11 @@ export const openApi: OpenApiRouteDoc = {
|
|
|
656
667
|
description: 'Record created',
|
|
657
668
|
schema: mutationResponseSchema,
|
|
658
669
|
},
|
|
670
|
+
{
|
|
671
|
+
status: 409,
|
|
672
|
+
description: 'Record ID already exists in the current scope',
|
|
673
|
+
schema: errorSchema,
|
|
674
|
+
},
|
|
659
675
|
{
|
|
660
676
|
status: 400,
|
|
661
677
|
description: 'Validation failure',
|
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
"entities.nav.systemEntities": "Systementitäten",
|
|
197
197
|
"entities.nav.userEntities": "Benutzerentitäten",
|
|
198
198
|
"entities.nav.userEntities.create": "Entität erstellen",
|
|
199
|
+
"entities.records.errors.alreadyExists": "Ein Datensatz mit dieser ID existiert bereits. Verwenden Sie PUT, um ihn zu aktualisieren.",
|
|
199
200
|
"entities.settings.description": "Globale Konfigurationen für benutzerdefinierte Entitäten verwalten.",
|
|
200
201
|
"entities.settings.errors.conflict": "Die Einstellungen wurden von einem anderen Benutzer geändert. Bitte neu laden.",
|
|
201
202
|
"entities.settings.errors.loadFailed": "Laden der Einstellungen fehlgeschlagen",
|
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
"entities.nav.systemEntities": "System Entities",
|
|
197
197
|
"entities.nav.userEntities": "User Entities",
|
|
198
198
|
"entities.nav.userEntities.create": "Create Entity",
|
|
199
|
+
"entities.records.errors.alreadyExists": "A record with this ID already exists. Use PUT to update it.",
|
|
199
200
|
"entities.settings.description": "Manage global custom entity configurations.",
|
|
200
201
|
"entities.settings.errors.conflict": "Settings were modified by another user. Please reload.",
|
|
201
202
|
"entities.settings.errors.loadFailed": "Failed to load settings",
|
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
"entities.nav.systemEntities": "Entidades del sistema",
|
|
197
197
|
"entities.nav.userEntities": "Entidades de usuario",
|
|
198
198
|
"entities.nav.userEntities.create": "Crear entidad",
|
|
199
|
+
"entities.records.errors.alreadyExists": "Ya existe un registro con este ID. Utilice PUT para actualizarlo.",
|
|
199
200
|
"entities.settings.description": "Administrar configuraciones globales de entidades personalizadas.",
|
|
200
201
|
"entities.settings.errors.conflict": "La configuración fue modificada por otro usuario. Por favor recarga.",
|
|
201
202
|
"entities.settings.errors.loadFailed": "Error al cargar la configuración",
|
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
"entities.nav.systemEntities": "시스템 엔터티",
|
|
197
197
|
"entities.nav.userEntities": "사용자 엔터티",
|
|
198
198
|
"entities.nav.userEntities.create": "엔터티 생성",
|
|
199
|
+
"entities.records.errors.alreadyExists": "이 ID의 레코드가 이미 존재합니다. 업데이트하려면 PUT을 사용하세요.",
|
|
199
200
|
"entities.settings.description": "전역 사용자 지정 엔터티 구성을 관리합니다.",
|
|
200
201
|
"entities.settings.errors.conflict": "다른 사용자가 설정을 수정했습니다. 새로고침하세요.",
|
|
201
202
|
"entities.settings.errors.loadFailed": "설정을 로드하지 못했습니다.",
|
|
@@ -196,6 +196,7 @@
|
|
|
196
196
|
"entities.nav.systemEntities": "Encje systemowe",
|
|
197
197
|
"entities.nav.userEntities": "Encje użytkownika",
|
|
198
198
|
"entities.nav.userEntities.create": "Utwórz encję",
|
|
199
|
+
"entities.records.errors.alreadyExists": "Rekord o tym identyfikatorze już istnieje. Użyj PUT, aby go zaktualizować.",
|
|
199
200
|
"entities.settings.description": "Zarządzaj globalnymi ustawieniami niestandardowych encji.",
|
|
200
201
|
"entities.settings.errors.conflict": "Ustawienia zostały zmodyfikowane przez innego użytkownika. Proszę przeładować.",
|
|
201
202
|
"entities.settings.errors.loadFailed": "Nie udało się załadować ustawień",
|
|
@@ -89,6 +89,7 @@ The consulting suite added the routes below. The URL namespace stays `staff/time
|
|
|
89
89
|
| `GET/POST/PUT/DELETE /tasks`, `PATCH /tasks/[id]/status`, `GET/POST/PUT/DELETE /tasks/[id]/comments` | `tasks.view` / `tasks.manage` | Tasks are depth-1 (a subtask is a child task, D-2). Every query is intersected with `resolveProjectAccess`. `?tagIds=` narrows to tasks carrying **every** listed tag. |
|
|
90
90
|
| `GET/POST/PUT/DELETE /task-statuses` | `tasks.view` / `tasks.manage` | Per-project Kanban columns (D-1), seeded when a project is created. |
|
|
91
91
|
| `GET/POST/PUT/DELETE /tags`, `POST/DELETE /tags/{task,entry}-assignments` | `tasks.view` / `tasks.manage` | Refuses to tag an entry locked in a closed report. |
|
|
92
|
+
| `GET /time-entries?includeTotals=true` | `view` | Adds an optional `totals` (`entryCount`, `durationMinutes`, `roundedMinutes`; `money` per currency only for `rates.view`) over the WHOLE filtered set, computed by `lib/timesheets/timeEntryTotals.ts` from the same `buildScopedTimeEntryListFilters` output. `money` is grouped in SQL but priced per group by `entryAmount` and summed in cents, so it always equals the summed row `cost` — never price it in SQL, whose exact-decimal rounding disagrees with `entryAmount` on half cents. A filter operator the aggregate cannot compile throws, is reported as `staff.time_entry_totals_failed`, and `totals` is omitted — extend `compileTimeEntryFilters` when the list filters grow. The entries page renders it inside the table via the `data-table:staff.time_entries.list:footer` widget. |
|
|
92
93
|
| `POST /time-entries/[id]/duplicate`, `POST /time-entries/copy-day`, `GET /time-entries/overlaps` | `manage_own` / `view` | Overlaps is advisory and midnight-aware (D-8); copy-day refuses a non-empty target unless `allowDuplicates`. |
|
|
93
94
|
| `GET/POST/PUT/DELETE /reports`, `POST /reports/preview`, `GET /reports/[id]/sheet`, `POST /reports/[id]/{close,unlock}`, `GET /reports/[id]/export` | `reports.view` / `reports.manage` / `reports.unlock` | Close freezes per-entry values and locks the entries; unlock requires a reason. Export never locks. |
|
|
94
95
|
| `GET /my-work` | `view` | Screen 1 aggregate; always the caller's own, no `staffMemberId` parameter exists. |
|