From a6a392cd0bfbc52d466e59c18dcfd1e461f92aa3 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:38:18 +0200 Subject: [PATCH 1/6] feat(ui): add the time API client, its types and the timer store The time screens need three things the projects slice does not: a client for the entry, timer and status endpoints, somewhere to keep the running clock, and the session facts a module bundle cannot read from the host stores. The timer is a plain reactive singleton because a module has no Pinia. It recomputes the elapsed seconds from `started_at` on every tick rather than counting up, so a throttled tab, a sleeping laptop and a clock correction all land on the right number at the next tick, and a 409 from `timer/start` is reported and followed by a read rather than treated as a failure. Task names live in their own cache: `TimeEntryResource` carries `task_id` and nothing else, so every timesheet row, the header chip and the entry editor would otherwise show a bare number. --- resources/js/api/time.ts | 212 ++++++++++++++++++++++++++++ resources/js/messages/time.ts | 140 +++++++++++++++++++ resources/js/stores/session.ts | 109 +++++++++++++++ resources/js/stores/tasks.ts | 79 +++++++++++ resources/js/stores/timer.ts | 213 +++++++++++++++++++++++++++++ resources/js/support/http.ts | 27 ++++ resources/js/support/time.ts | 206 ++++++++++++++++++++++++++++ resources/js/types/task-status.ts | 17 +++ resources/js/types/task-summary.ts | 11 ++ resources/js/types/time-entry.ts | 54 ++++++++ resources/js/types/timer.ts | 15 ++ 11 files changed, 1083 insertions(+) create mode 100644 resources/js/api/time.ts create mode 100644 resources/js/messages/time.ts create mode 100644 resources/js/stores/session.ts create mode 100644 resources/js/stores/tasks.ts create mode 100644 resources/js/stores/timer.ts create mode 100644 resources/js/support/http.ts create mode 100644 resources/js/support/time.ts create mode 100644 resources/js/types/task-status.ts create mode 100644 resources/js/types/task-summary.ts create mode 100644 resources/js/types/time-entry.ts create mode 100644 resources/js/types/timer.ts diff --git a/resources/js/api/time.ts b/resources/js/api/time.ts new file mode 100644 index 0000000..69e678b --- /dev/null +++ b/resources/js/api/time.ts @@ -0,0 +1,212 @@ +import type { AxiosInstance } from 'axios' +import type { Paginated, Wrapped } from '@/types/api' +import type { CompanyMember } from '@/types/member' +import type { ModuleSettings } from '@/types/settings' +import type { TaskStatus, TaskStatusInput } from '@/types/task-status' +import type { TaskSummary } from '@/types/task-summary' +import type { TimeEntry, TimeEntryInput, TimeEntryListParams } from '@/types/time-entry' +import type { RunningTimer, StartTimerInput } from '@/types/timer' + +const BASE = '/api/v1/tasks-projects' + +/** The endpoints the time screens talk to. */ +export const TIME_API = { + timeEntries: `${BASE}/time-entries`, + timeEntry: (id: number): string => `${BASE}/time-entries/${id}`, + timer: `${BASE}/timer`, + timerStart: `${BASE}/timer/start`, + timerStop: `${BASE}/timer/stop`, + taskStatuses: `${BASE}/task-statuses`, + taskStatus: (id: number): string => `${BASE}/task-statuses/${id}`, + reorderTaskStatuses: `${BASE}/task-statuses/reorder`, + tasks: `${BASE}/tasks`, + task: (id: number): string => `${BASE}/tasks/${id}`, + members: `${BASE}/members`, + settings: `${BASE}/settings`, +} as const + +/** Host endpoints the module reads through the same session client. */ +export const HOST_TIME_API = { + bootstrap: '/api/v1/bootstrap', +} as const + +/** How many rows one list request asks for, and how many it may ever ask for. */ +export const TIME_PAGE_SIZE = 25 + +const WEEK_PAGE_SIZE = 100 +const MAX_WEEK_PAGES = 5 +const TASK_SEARCH_LIMIT = 10 + +export async function listTimeEntries( + client: AxiosInstance, + params: TimeEntryListParams, +): Promise> { + const { data } = await client.get>(TIME_API.timeEntries, { params }) + + return data +} + +/** + * Every entry of one range, rather than one page of them. + * + * The week grid has to show whole days, so it follows the paginator instead of + * cutting the last day in half. The page walk is bounded: a week with more + * than five hundred entries is a data problem, not a view to render. + */ +export async function listAllTimeEntries( + client: AxiosInstance, + params: TimeEntryListParams, +): Promise { + const entries: TimeEntry[] = [] + + for (let page = 1; page <= MAX_WEEK_PAGES; page += 1) { + const response = await listTimeEntries(client, { ...params, page, limit: WEEK_PAGE_SIZE }) + + entries.push(...(response.data ?? [])) + + if (!response.meta || page >= response.meta.last_page) { + break + } + } + + return entries +} + +export async function createTimeEntry( + client: AxiosInstance, + input: TimeEntryInput, +): Promise { + const { data } = await client.post>(TIME_API.timeEntries, input) + + return data.data +} + +export async function updateTimeEntry( + client: AxiosInstance, + id: number, + input: TimeEntryInput, +): Promise { + const { data } = await client.put>(TIME_API.timeEntry(id), input) + + return data.data +} + +export async function deleteTimeEntry(client: AxiosInstance, id: number): Promise { + await client.delete(TIME_API.timeEntry(id)) +} + +/** The caller's running entry, or null when the clock is not running. */ +export async function fetchTimer(client: AxiosInstance): Promise { + const { data } = await client.get(TIME_API.timer) + + return data?.data ?? null +} + +export async function startTimer( + client: AxiosInstance, + input: StartTimerInput, +): Promise { + const { data } = await client.post>(TIME_API.timerStart, input) + + return data.data +} + +export async function stopTimer(client: AxiosInstance): Promise { + const { data } = await client.post>(TIME_API.timerStop) + + return data.data +} + +export async function discardTimer(client: AxiosInstance): Promise { + await client.delete(TIME_API.timer) +} + +export async function listTaskStatuses(client: AxiosInstance): Promise { + const { data } = await client.get>(TIME_API.taskStatuses) + + return data.data ?? [] +} + +export async function createTaskStatus( + client: AxiosInstance, + input: TaskStatusInput, +): Promise { + const { data } = await client.post>(TIME_API.taskStatuses, input) + + return data.data +} + +export async function updateTaskStatus( + client: AxiosInstance, + id: number, + input: TaskStatusInput, +): Promise { + const { data } = await client.put>(TIME_API.taskStatus(id), input) + + return data.data +} + +export async function deleteTaskStatus(client: AxiosInstance, id: number): Promise { + await client.delete(TIME_API.taskStatus(id)) +} + +/** Apply the wanted column order; the endpoint answers with the new list. */ +export async function reorderTaskStatuses( + client: AxiosInstance, + ids: number[], +): Promise { + const { data } = await client.post>(TIME_API.reorderTaskStatuses, { ids }) + + return data.data ?? [] +} + +/** Tasks matching what the picker has typed so far. */ +export async function searchTasks( + client: AxiosInstance, + search: string, + limit = TASK_SEARCH_LIMIT, +): Promise { + const params: Record = { limit } + + if (search.trim() !== '') { + params.search = search.trim() + } + + const { data } = await client.get>(TIME_API.tasks, { params }) + + return data.data ?? [] +} + +export async function fetchTask(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(TIME_API.task(id)) + + return data.data +} + +/** The company's members, for the member filter and the "who logged it" column. */ +export async function listTimeMembers(client: AxiosInstance): Promise { + const { data } = await client.get>(TIME_API.members) + + return data.data ?? [] +} + +export async function fetchTimeSettings(client: AxiosInstance): Promise { + const { data } = await client.get>(TIME_API.settings) + + return data.data +} + +/** + * The signed-in user's id, read from the host bootstrap payload. + * + * A module bundle has no access to the host's user store, and the time + * endpoints answer "my time" only when they are asked for a specific + * `user_id`, so the id is fetched once per company session from the same + * round trip the shell itself uses. + */ +export async function fetchCurrentUserId(client: AxiosInstance): Promise { + const { data } = await client.get<{ current_user?: { id?: unknown } }>(HOST_TIME_API.bootstrap) + const id = data?.current_user?.id + + return typeof id === 'number' ? id : null +} diff --git a/resources/js/messages/time.ts b/resources/js/messages/time.ts new file mode 100644 index 0000000..2a65515 --- /dev/null +++ b/resources/js/messages/time.ts @@ -0,0 +1,140 @@ +/** + * Every string the time screens render. + * + * Kept apart from the projects bundle so the two slices never edit the same + * file; the host merges both into one catalogue, so a template still reaches + * these with `$t('tasks_projects.time....')`. + */ +export const timeMessages = { + en: { + tasks_projects: { + time: { + title: 'Time', + my_time: 'My time', + all_time: 'All time', + this_week: 'This week', + previous_week: 'Previous week', + next_week: 'Next week', + week_total: 'Week total', + day_total: 'Total', + add_entry: 'Add entry', + new_entry: 'New time entry', + edit_entry: 'Edit time entry', + view_entry: 'Time entry', + no_entries: 'Nothing logged.', + empty_title: 'No time logged yet', + empty_description: 'Log an entry by hand, or start the timer on a task.', + unknown_member: 'Removed member', + unknown_user: 'Unable to identify the signed-in user. Reload the page and try again.', + billable: 'Billable', + non_billable: 'Not billable', + billed: 'Billed', + unbilled: 'Unbilled', + stamped_notice: + 'This entry is already on an invoice. Invoiced time is history and cannot be changed.', + created: 'The time entry was saved.', + updated: 'The time entry was updated.', + deleted: 'The time entry was deleted.', + delete_confirm: 'Delete this time entry?', + load_failed: 'Unable to load the time entries.', + save_failed: 'Unable to save the time entry.', + delete_failed: 'Unable to delete the time entry.', + members_failed: 'Unable to load the members.', + projects_failed: 'Unable to load the projects.', + tasks_failed: 'Unable to load the tasks.', + columns: { + date: 'Date', + member: 'Member', + task: 'Task', + description: 'Description', + duration: 'Duration', + billable: 'Billable', + amount: 'Amount', + }, + filters: { + member: 'Member', + project: 'Project', + from: 'From', + to: 'To', + billing: 'Billing', + all: 'All', + any_member: 'Everyone', + any_project: 'Any project', + }, + fields: { + task: 'Task', + task_placeholder: 'Search by task name or number', + date: 'Date', + mode: 'Entry', + duration: 'Duration', + duration_help: 'Hours and minutes, as 1:30 or 1.5.', + start: 'Start', + end: 'End', + description: 'Description', + billable: 'Billable', + }, + mode: { + duration: 'Duration', + range: 'Start and end', + }, + task_required: 'Pick a task.', + date_required: 'Pick a date.', + duration_invalid: 'Enter a duration like 1:30 or 1.5.', + range_invalid: 'Enter a start and an end time, with the end after the start.', + }, + timer: { + running: 'Timer running', + quick_start: 'Start a timer', + panel_title: 'Quick start', + start: 'Start', + stop: 'Stop', + discard: 'Discard', + close: 'Close', + open_timesheet: 'Open my time', + elapsed: 'Elapsed', + search_tasks: 'Search tasks', + no_tasks: 'No tasks match that search.', + description_placeholder: 'What are you working on? (optional)', + started: 'The timer is running on {name}.', + stopped: 'Logged {duration} on {name}.', + discarded: 'The running timer was discarded.', + discard_confirm: 'Discard the running timer? The elapsed time is not saved.', + already_running: 'A timer is already running. It has been reloaded.', + start_failed: 'Unable to start the timer.', + stop_failed: 'Unable to stop the timer.', + discard_failed: 'Unable to discard the timer.', + }, + settings: { + title: 'Tasks and Projects', + general_title: 'General', + general_description: + 'The default hourly rate, the rounding increment, the first day of the week and who may see other members time.', + open_module_settings: 'Open module settings', + statuses_title: 'Task statuses', + statuses_description: + 'The columns of the board. One status is the default, where new tasks land; a closed status counts as done.', + status_name: 'Name', + colour: 'Colour', + colour_none: 'None', + is_default: 'Default', + is_closed: 'Closed', + add_status: 'Add status', + new_status: 'New status', + move_up: 'Move up', + move_down: 'Move down', + no_statuses: 'No statuses yet.', + status_created: '{name} was added.', + status_updated: '{name} was updated.', + status_deleted: '{name} was deleted.', + status_reordered: 'The order was saved.', + status_delete_confirm: 'Delete {name}?', + status_name_required: 'Enter a status name.', + load_failed: 'Unable to load the task statuses.', + save_failed: 'Unable to save the task status.', + delete_failed: 'Unable to delete the task status.', + reorder_failed: 'Unable to save the new order.', + forbidden: 'Your role does not allow managing the board columns.', + }, + }, + }, +} diff --git a/resources/js/stores/session.ts b/resources/js/stores/session.ts new file mode 100644 index 0000000..4f153b9 --- /dev/null +++ b/resources/js/stores/session.ts @@ -0,0 +1,109 @@ +import { reactive } from 'vue' +import type { AxiosInstance } from 'axios' +import { fetchCurrentUserId, fetchTimeSettings } from '@/api/time' +import type { ModuleSettings } from '@/types/settings' + +/** + * What the time screens need to know about the current session. + * + * A module bundle runs on the host's Vue instance but not on its Pinia, so it + * cannot read the user or company stores. The two facts the timesheet cannot + * work without, the signed-in user's id and the company's module settings, are + * fetched once per company and kept here, where the page, the overlay and the + * settings editor all reach them. + * + * Everything degrades rather than throws: a member who may not read the module + * settings still gets the defaults, and a missing user id only means the "My + * time" view asks the caller to reload. + */ + +export const DEFAULT_SETTINGS: ModuleSettings = { + default_rate: 0, + rounding_minutes: 1, + week_start: 1, + members_see_all_time: false, + rounding_increments: [1, 6, 15, 30], +} + +interface SessionState { + /** True while the shell is in platform administration, where no company is active. */ + adminMode: boolean + userId: number | null + settings: ModuleSettings + /** Bumped on every company change so views can key off it and start clean. */ + companySession: number + loading: boolean +} + +export const session = reactive({ + adminMode: false, + userId: null, + settings: { ...DEFAULT_SETTINGS }, + companySession: 0, + loading: false, +}) + +/** Read the user id and the company settings for the active company. */ +export async function refreshSession(client: AxiosInstance): Promise { + if (session.adminMode) { + return + } + + session.loading = true + + const [userId, settings] = await Promise.all([ + fetchCurrentUserId(client).catch((): null => null), + fetchTimeSettings(client).catch((): null => null), + ]) + + session.userId = userId + session.settings = normaliseSettings(settings) + session.loading = false +} + +/** Forget the previous company's answers before the next one loads. */ +export function resetSession(): void { + session.userId = null + session.settings = { ...DEFAULT_SETTINGS } + session.companySession += 1 + session.loading = false +} + +export function setAdminMode(adminMode: boolean): void { + session.adminMode = adminMode +} + +/** + * Settings as the module promises them, whatever the endpoint answered. + * + * The payload is host data crossing a module boundary, so every field is + * checked rather than trusted: a missing settings endpoint, an older module + * version or a 403 all end up as the documented defaults. + */ +function normaliseSettings(settings: ModuleSettings | null): ModuleSettings { + if (settings === null || typeof settings !== 'object') { + return { ...DEFAULT_SETTINGS } + } + + const increments = Array.isArray(settings.rounding_increments) + ? settings.rounding_increments.filter((value): value is number => typeof value === 'number') + : DEFAULT_SETTINGS.rounding_increments + + return { + default_rate: numberOr(settings.default_rate, DEFAULT_SETTINGS.default_rate), + rounding_minutes: numberOr(settings.rounding_minutes, DEFAULT_SETTINGS.rounding_minutes), + week_start: weekStartOr(settings.week_start), + members_see_all_time: settings.members_see_all_time === true, + rounding_increments: increments.length > 0 ? increments : DEFAULT_SETTINGS.rounding_increments, + } +} + +function numberOr(value: unknown, fallback: number): number { + return typeof value === 'number' && Number.isFinite(value) ? value : fallback +} + +function weekStartOr(value: unknown): number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6 + ? value + : DEFAULT_SETTINGS.week_start +} diff --git a/resources/js/stores/tasks.ts b/resources/js/stores/tasks.ts new file mode 100644 index 0000000..c281c8c --- /dev/null +++ b/resources/js/stores/tasks.ts @@ -0,0 +1,79 @@ +import { reactive } from 'vue' +import type { AxiosInstance } from 'axios' +import { fetchTask } from '@/api/time' +import type { TaskSummary } from '@/types/task-summary' + +/** + * A name for every task id the time screens display. + * + * `TimeEntryResource` carries `task_id` and nothing else, so a timesheet row, + * the header chip and the entry editor would all show a bare number. The cache + * fills in the names, once per task per company session, and a task that + * cannot be read keeps its id as the label rather than blanking the row. + */ + +const names = reactive>({}) +const pending = new Set() + +/** How many name lookups may be in flight at once. */ +const BATCH_SIZE = 5 + +/** The cached name of a task, or null while it is still unknown. */ +export function taskName(id: number | null): string | null { + return id === null ? null : (names[id] ?? null) +} + +/** The cached name, or a stable `#id` placeholder to render meanwhile. */ +export function taskLabel(id: number | null): string { + if (id === null) { + return '' + } + + return names[id] ?? `#${id}` +} + +/** Remember a task the caller already holds, so no lookup is needed. */ +export function rememberTask(task: TaskSummary | null | undefined): void { + if (task && typeof task.id === 'number' && typeof task.name === 'string') { + names[task.id] = task.name + } +} + +/** + * Make sure every id given has a name, fetching the ones that do not. + * + * Failures are swallowed on purpose: a missing name is cosmetic, and the + * timesheet must render even when one task has been deleted under it. + */ +export async function ensureTaskNames(client: AxiosInstance, ids: number[]): Promise { + const wanted = [...new Set(ids)].filter( + (id) => typeof id === 'number' && names[id] === undefined && !pending.has(id), + ) + + for (const id of wanted) { + pending.add(id) + } + + for (let index = 0; index < wanted.length; index += BATCH_SIZE) { + await Promise.all( + wanted.slice(index, index + BATCH_SIZE).map(async (id) => { + try { + rememberTask(await fetchTask(client, id)) + } catch { + // A task that cannot be read keeps its id as its label. + } finally { + pending.delete(id) + } + }), + ) + } +} + +/** Drop everything: task ids belong to one company. */ +export function resetTaskNames(): void { + for (const key of Object.keys(names)) { + delete names[Number(key)] + } + + pending.clear() +} diff --git a/resources/js/stores/timer.ts b/resources/js/stores/timer.ts new file mode 100644 index 0000000..80fc19c --- /dev/null +++ b/resources/js/stores/timer.ts @@ -0,0 +1,213 @@ +import { reactive } from 'vue' +import type { AxiosInstance } from 'axios' +import { + discardTimer, + fetchTimer, + startTimer, + stopTimer, +} from '@/api/time' +import { errorMessage } from '@/support/errors' +import { isConflict } from '@/support/http' +import { secondsSince } from '@/support/time' +import type { Translate } from '@/support/i18n' +import type { TimeEntry } from '@/types/time-entry' +import { ensureTaskNames } from './tasks' + +/** + * The running timer, shared by the header chip, the quick-start launcher and + * the timesheet. + * + * A module bundle has no Pinia, so this is a plain reactive singleton. The + * elapsed time is recomputed from `started_at` on every tick rather than + * counted up, so a throttled background tab, a sleeping laptop and a clock + * correction all land on the right number at the next tick. + * + * Nothing here throws at a caller: a failed refresh leaves the chip hidden + * rather than breaking the header it renders in. + */ + +type NotifyType = 'success' | 'error' | 'warning' | 'info' + +/** + * How a caller wants failures reported. The translator comes from the calling + * component, because a store cannot reach the host's i18n on its own. + */ +export interface TimerFeedback { + notify: (type: NotifyType, message: string) => void + t: Translate +} + +interface TimerState { + running: TimeEntry | null + elapsedSeconds: number + /** True while a start, stop or discard is in flight, to disable the buttons. */ + busy: boolean +} + +const state = reactive({ + running: null, + elapsedSeconds: 0, + busy: false, +}) + +let ticker: ReturnType | undefined + +function tick(): void { + state.elapsedSeconds = state.running === null ? 0 : secondsSince(state.running.started_at) +} + +function startTicking(): void { + tick() + + if (ticker === undefined) { + ticker = setInterval(tick, 1000) + } +} + +function stopTicking(): void { + if (ticker !== undefined) { + clearInterval(ticker) + ticker = undefined + } + + state.elapsedSeconds = 0 +} + +/** Adopt a payload as the running entry, or clear the clock when it is null. */ +function adopt(entry: TimeEntry | null, client?: AxiosInstance): void { + state.running = entry && typeof entry.id === 'number' ? entry : null + + if (state.running === null) { + stopTicking() + + return + } + + startTicking() + + if (client && typeof state.running.task_id === 'number') { + void ensureTaskNames(client, [state.running.task_id]) + } +} + +function report(feedback: TimerFeedback | undefined, error: unknown, key: string): void { + feedback?.notify('error', errorMessage(error, feedback.t(key))) +} + +export const timerStore = { + /** The running entry, or null when the clock is not running. */ + get running(): TimeEntry | null { + return state.running + }, + + /** Seconds since the running entry started, recomputed every second. */ + get elapsedSeconds(): number { + return state.elapsedSeconds + }, + + /** True while a timer request is in flight. */ + get busy(): boolean { + return state.busy + }, + + /** Read the caller's running entry from the server. */ + async refresh(client: AxiosInstance): Promise { + try { + adopt(await fetchTimer(client), client) + } catch { + // The header chip stays hidden rather than reporting a background read. + adopt(null) + } + }, + + /** + * Start the clock on a task. + * + * A 409 means another tab got there first, which is not an error the user + * caused: it is reported and the real running entry is read back. + */ + async start( + client: AxiosInstance, + taskId: number, + description: string | null = null, + feedback?: TimerFeedback, + ): Promise { + if (state.busy) { + return null + } + + state.busy = true + + try { + const entry = await startTimer(client, { task_id: taskId, description }) + + adopt(entry, client) + + return entry + } catch (error: unknown) { + if (isConflict(error)) { + feedback?.notify('warning', feedback.t('tasks_projects.timer.already_running')) + await this.refresh(client) + } else { + report(feedback, error, 'tasks_projects.timer.start_failed') + } + + return null + } finally { + state.busy = false + } + }, + + /** Close the running entry and answer the completed one. */ + async stop(client: AxiosInstance, feedback?: TimerFeedback): Promise { + if (state.busy || state.running === null) { + return null + } + + state.busy = true + + try { + const entry = await stopTimer(client) + + adopt(null) + + return entry + } catch (error: unknown) { + report(feedback, error, 'tasks_projects.timer.stop_failed') + await this.refresh(client) + + return null + } finally { + state.busy = false + } + }, + + /** Throw the running entry away without recording any time. */ + async discard(client: AxiosInstance, feedback?: TimerFeedback): Promise { + if (state.busy || state.running === null) { + return false + } + + state.busy = true + + try { + await discardTimer(client) + adopt(null) + + return true + } catch (error: unknown) { + report(feedback, error, 'tasks_projects.timer.discard_failed') + await this.refresh(client) + + return false + } finally { + state.busy = false + } + }, + + /** Forget the clock, for a company switch or a sign-out. */ + reset(): void { + state.busy = false + adopt(null) + }, +} diff --git a/resources/js/support/http.ts b/resources/js/support/http.ts new file mode 100644 index 0000000..3c9ee01 --- /dev/null +++ b/resources/js/support/http.ts @@ -0,0 +1,27 @@ +/** + * The status of a failed request, read structurally. + * + * The module bundle runs on the host's axios instance and never imports axios + * at runtime, so the error is inspected rather than narrowed with + * `axios.isAxiosError`. A missing or unparseable response answers null, which + * callers treat as "some other failure". + */ +export function errorStatus(error: unknown): number | null { + if (typeof error !== 'object' || error === null) { + return null + } + + const status = (error as { response?: { status?: unknown } }).response?.status + + return typeof status === 'number' ? status : null +} + +/** A 409 from `timer/start`: someone else's tab already started the clock. */ +export function isConflict(error: unknown): boolean { + return errorStatus(error) === 409 +} + +/** A 403: the caller lacks the ability the endpoint asks for. */ +export function isForbidden(error: unknown): boolean { + return errorStatus(error) === 403 +} diff --git a/resources/js/support/time.ts b/resources/js/support/time.ts new file mode 100644 index 0000000..cadd8c1 --- /dev/null +++ b/resources/js/support/time.ts @@ -0,0 +1,206 @@ +/** + * Clocks, durations and weeks. + * + * The API stores instants in UTC and durations in whole minutes; the timesheet + * talks in the viewer's own day, so every conversion here goes through the + * browser's local time zone and never through a string comparison of two + * differently offset timestamps. + */ + +const MINUTES_PER_HOUR = 60 +const SECONDS_PER_MINUTE = 60 +const DAYS_PER_WEEK = 7 + +/** Seconds as `h:mm:ss`, which is what a running timer shows. */ +export function formatClock(seconds: number): string { + const total = Number.isFinite(seconds) && seconds > 0 ? Math.floor(seconds) : 0 + const hours = Math.floor(total / (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)) + const minutes = Math.floor((total % (SECONDS_PER_MINUTE * MINUTES_PER_HOUR)) / SECONDS_PER_MINUTE) + const rest = total % SECONDS_PER_MINUTE + + return `${hours}:${pad(minutes)}:${pad(rest)}` +} + +/** Minutes as `h:mm`, which is how a logged duration is written and typed. */ +export function formatDuration(minutes: number | null): string { + const total = minutes !== null && Number.isFinite(minutes) && minutes > 0 ? Math.round(minutes) : 0 + + return `${Math.floor(total / MINUTES_PER_HOUR)}:${pad(total % MINUTES_PER_HOUR)}` +} + +/** + * A typed duration back to minutes. + * + * Both notations people actually use are accepted: `1:30` and the decimal + * `1.5`. Anything else answers null so the caller can mark the field invalid + * rather than silently logging zero. + */ +export function parseDuration(value: string): number | null { + const text = value.trim() + + if (text === '') { + return null + } + + const clock = /^(\d+):([0-5]?\d)$/.exec(text) + + if (clock) { + return Number(clock[1]) * MINUTES_PER_HOUR + Number(clock[2]) + } + + if (!/^\d+([.,]\d+)?$/.test(text)) { + return null + } + + const hours = Number(text.replace(',', '.')) + + return Number.isNaN(hours) ? null : Math.round(hours * MINUTES_PER_HOUR) +} + +/** The local calendar date of an instant, as the `Y-m-d` the API takes. */ +export function localDateOf(instant: string | null): string { + const date = parseInstant(instant) + + return date === null ? '' : formatLocalDate(date) +} + +/** The local wall-clock time of an instant, as the `HH:MM` an input shows. */ +export function localTimeOf(instant: string | null): string { + const date = parseInstant(instant) + + return date === null ? '' : `${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +/** + * A local date and an optional `HH:MM` back to the instant the API stores. + * + * The pair is read as local wall-clock time, so an entry typed as "the 3rd, + * 09:00" stays on the 3rd at 09:00 for the person who typed it whatever their + * offset is. A duration-only entry gets a default hour rather than midnight, + * which would land on the previous day for anyone east of UTC. + */ +export function localInstant(date: string, time = '09:00'): string | null { + const day = parseDateString(date) + const clock = /^(\d{1,2}):([0-5]\d)$/.exec(time.trim()) + + if (day === null || clock === null) { + return null + } + + const hours = Number(clock[1]) + + if (hours > 23) { + return null + } + + day.setHours(hours, Number(clock[2]), 0, 0) + + return day.toISOString() +} + +/** The same instant moved by whole minutes, for the end of a typed duration. */ +export function addMinutes(instant: string, minutes: number): string { + const date = new Date(instant) + + date.setTime(date.getTime() + minutes * SECONDS_PER_MINUTE * 1000) + + return date.toISOString() +} + +/** Local midnight of the `Y-m-d` given, or of today when it is unreadable. */ +export function dayOf(date: string): Date { + return parseDateString(date) ?? startOfDay(new Date()) +} + +/** + * The first day of the week `date` falls in. + * + * `weekStart` is the company setting, 0 for Sunday through 6 for Saturday; an + * out-of-range value falls back to Monday rather than shifting the grid. + */ +export function startOfWeek(date: Date, weekStart: number): Date { + const first = Number.isInteger(weekStart) && weekStart >= 0 && weekStart <= 6 ? weekStart : 1 + const start = startOfDay(date) + const shift = (start.getDay() - first + DAYS_PER_WEEK) % DAYS_PER_WEEK + + start.setDate(start.getDate() - shift) + + return start +} + +/** The seven days of the week beginning at `start`. */ +export function weekDays(start: Date): Date[] { + return Array.from({ length: DAYS_PER_WEEK }, (_unused, index) => addDays(start, index)) +} + +export function addDays(date: Date, days: number): Date { + const shifted = startOfDay(date) + + shifted.setDate(shifted.getDate() + days) + + return shifted +} + +/** A `Date` as the `Y-m-d` the API takes, in local time. */ +export function formatLocalDate(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +/** The weekday and day-of-month a column of the week grid is labelled with. */ +export function dayLabel(date: Date): { weekday: string; day: string } { + return { + weekday: date.toLocaleDateString(undefined, { weekday: 'short' }), + day: date.toLocaleDateString(undefined, { day: 'numeric', month: 'short' }), + } +} + +/** Whether a date is today, so the grid can mark the column. */ +export function isToday(date: Date): boolean { + return formatLocalDate(date) === formatLocalDate(new Date()) +} + +/** Seconds elapsed since an instant, never negative and never NaN. */ +export function secondsSince(instant: string | null): number { + const start = parseInstant(instant) + + if (start === null) { + return 0 + } + + return Math.max(0, Math.floor((Date.now() - start.getTime()) / 1000)) +} + +function parseInstant(instant: string | null): Date | null { + if (!instant) { + return null + } + + const date = new Date(instant) + + return Number.isNaN(date.getTime()) ? null : date +} + +/** A `Y-m-d` at local midnight. Anything else answers null. */ +function parseDateString(value: string): Date | null { + const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(value.trim()) + + if (match === null) { + return null + } + + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]), 0, 0, 0, 0) + + return Number.isNaN(date.getTime()) ? null : date +} + +function startOfDay(date: Date): Date { + const copy = new Date(date.getTime()) + + copy.setHours(0, 0, 0, 0) + + return copy +} + +function pad(value: number): string { + return String(value).padStart(2, '0') +} diff --git a/resources/js/types/task-status.ts b/resources/js/types/task-status.ts new file mode 100644 index 0000000..19b3d85 --- /dev/null +++ b/resources/js/types/task-status.ts @@ -0,0 +1,17 @@ +/** One column of the board, as `TaskStatusResource` renders it. */ +export interface TaskStatus { + id: number + name: string + colour: string | null + position: number + is_default: boolean + is_closed: boolean +} + +/** What the create and update endpoints accept. */ +export interface TaskStatusInput { + name?: string + colour?: string | null + is_default?: boolean + is_closed?: boolean +} diff --git a/resources/js/types/task-summary.ts b/resources/js/types/task-summary.ts new file mode 100644 index 0000000..7b4e224 --- /dev/null +++ b/resources/js/types/task-summary.ts @@ -0,0 +1,11 @@ +/** + * The few fields of a task the time screens need: enough to search for one, + * label an entry and know whether logging against it is billable by default. + */ +export interface TaskSummary { + id: number + name: string + number: number | null + project_id: number | null + billable: boolean +} diff --git a/resources/js/types/time-entry.ts b/resources/js/types/time-entry.ts new file mode 100644 index 0000000..ea1c01b --- /dev/null +++ b/resources/js/types/time-entry.ts @@ -0,0 +1,54 @@ +/** + * Logged time, as `TimeEntryResource` renders it. + * + * Durations are minutes, `rate` is minor units per hour and `amount` is the + * money frozen on the entry when it was saved, also in minor units. An entry + * carrying an `invoice_id` is stamped: it belongs to an invoice and the API + * refuses to delete it. + */ +export interface TimeEntry { + id: number + company_id: number + task_id: number + project_id: number | null + user_id: number + started_at: string | null + ended_at: string | null + duration_minutes: number + description: string | null + billable: boolean + rate: number + amount: number + currency_id: number | null + is_running: boolean + invoice_id: number | null + invoice_item_id: number | null + invoiced_at: string | null + created_at: string | null + updated_at: string | null +} + +/** What the create and update endpoints accept. */ +export interface TimeEntryInput { + task_id: number + user_id?: number | null + started_at?: string | null + ended_at?: string | null + duration_minutes?: number | null + description?: string | null + billable?: boolean +} + +export interface TimeEntryListParams { + page?: number + limit?: number + user_id?: number + project_id?: number + task_id?: number + /** `Y-m-d`, inclusive. */ + from?: string + /** `Y-m-d`, inclusive. */ + to?: string + billable?: boolean + billed?: boolean +} diff --git a/resources/js/types/timer.ts b/resources/js/types/timer.ts new file mode 100644 index 0000000..a17fff8 --- /dev/null +++ b/resources/js/types/timer.ts @@ -0,0 +1,15 @@ +import type { TimeEntry } from './time-entry' + +/** + * The timer endpoint answers with the caller's running entry or with null, so + * the payload is wrapped rather than a bare resource. + */ +export interface RunningTimer { + data: TimeEntry | null +} + +/** What `timer/start` accepts. */ +export interface StartTimerInput { + task_id: number + description?: string | null +} From f2188d890881070ed4dcdc81424e781325c86d75 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:38:27 +0200 Subject: [PATCH 2/6] feat(ui): add the timer chip, quick start, week grid and entry editor Six small components rather than two large ones, so a failure in any of them leaves the rest of the page standing: the header chip, the floating launcher, the week grid, the managers' table, the entry editor and the status editor. The editor accepts the two ways people describe the same work, a duration or a start and an end, and writes both onto the entry; a duration-only entry is stamped at 09:00 local rather than at midnight, which would land on the previous day for anyone east of UTC. An invoiced entry is shown and never offered for editing, because the money on it belongs to the invoice. The launcher sits above where a chat bubble would be, and hides itself in platform administration, where no company is active. --- resources/js/components/AllTimeTable.vue | 308 ++++++++++++ resources/js/components/QuickStartOverlay.vue | 264 ++++++++++ resources/js/components/TaskStatusEditor.vue | 364 ++++++++++++++ resources/js/components/TimeEntryModal.vue | 456 ++++++++++++++++++ resources/js/components/TimerChip.vue | 73 +++ resources/js/components/WeekTimesheet.vue | 257 ++++++++++ 6 files changed, 1722 insertions(+) create mode 100644 resources/js/components/AllTimeTable.vue create mode 100644 resources/js/components/QuickStartOverlay.vue create mode 100644 resources/js/components/TaskStatusEditor.vue create mode 100644 resources/js/components/TimeEntryModal.vue create mode 100644 resources/js/components/TimerChip.vue create mode 100644 resources/js/components/WeekTimesheet.vue diff --git a/resources/js/components/AllTimeTable.vue b/resources/js/components/AllTimeTable.vue new file mode 100644 index 0000000..1c1b3ef --- /dev/null +++ b/resources/js/components/AllTimeTable.vue @@ -0,0 +1,308 @@ + + + diff --git a/resources/js/components/QuickStartOverlay.vue b/resources/js/components/QuickStartOverlay.vue new file mode 100644 index 0000000..5087a1a --- /dev/null +++ b/resources/js/components/QuickStartOverlay.vue @@ -0,0 +1,264 @@ + + + diff --git a/resources/js/components/TaskStatusEditor.vue b/resources/js/components/TaskStatusEditor.vue new file mode 100644 index 0000000..26dbb01 --- /dev/null +++ b/resources/js/components/TaskStatusEditor.vue @@ -0,0 +1,364 @@ + + + diff --git a/resources/js/components/TimeEntryModal.vue b/resources/js/components/TimeEntryModal.vue new file mode 100644 index 0000000..e702966 --- /dev/null +++ b/resources/js/components/TimeEntryModal.vue @@ -0,0 +1,456 @@ + + + diff --git a/resources/js/components/TimerChip.vue b/resources/js/components/TimerChip.vue new file mode 100644 index 0000000..b4d8a3c --- /dev/null +++ b/resources/js/components/TimerChip.vue @@ -0,0 +1,73 @@ + + + diff --git a/resources/js/components/WeekTimesheet.vue b/resources/js/components/WeekTimesheet.vue new file mode 100644 index 0000000..028cf13 --- /dev/null +++ b/resources/js/components/WeekTimesheet.vue @@ -0,0 +1,257 @@ + + + From 2db4b87622da0a1e73414bf9444d10d33db5359d Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:38:36 +0200 Subject: [PATCH 3/6] feat(ui): register the timesheet, the launcher and the settings page Everything the slice contributes to the host is declared in one file, so `init.ts` only gains a line: the timesheet page under the module namespace, the header chip, the company layout overlay, the company settings page and the lifecycle wiring. Nothing reads from the network inside the boot callback, because Pinia is not installed yet when it runs; the first read waits for `bootstrap:completed`, and a company switch clears the previous company's timer, task names and settings before asking again. The settings page links to the host's generic module settings form for the four scalar settings rather than mirroring it, which would give a company two places to write the same value, and owns the task status editor, which has no host equivalent. The API still enforces `manage-task-status`, so a member who may not use it is told so instead of seeing a form that refuses to save. --- resources/js/init.ts | 3 + resources/js/pages/TimePage.vue | 233 ++++++++++++++++++++++++ resources/js/pages/TimeSettingsPage.vue | 57 ++++++ resources/js/registrations/time.ts | 147 +++++++++++++++ 4 files changed, 440 insertions(+) create mode 100644 resources/js/pages/TimePage.vue create mode 100644 resources/js/pages/TimeSettingsPage.vue create mode 100644 resources/js/registrations/time.ts diff --git a/resources/js/init.ts b/resources/js/init.ts index 2c4d998..44f5283 100644 --- a/resources/js/init.ts +++ b/resources/js/init.ts @@ -4,6 +4,7 @@ import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' import '../css/module.css' import { messages } from './messages' import ProjectsIndexPage from './pages/ProjectsIndexPage.vue' +import { registerTimeTracking } from './registrations/time' const MODULE = 'tasks-projects' @@ -20,6 +21,8 @@ window.InvoiceShelf.booting((_app, _router, extensions) => { title: 'tasks_projects.projects.title', }, }) + + registerTimeTracking(extensions) }) /** diff --git a/resources/js/pages/TimePage.vue b/resources/js/pages/TimePage.vue new file mode 100644 index 0000000..f2415d0 --- /dev/null +++ b/resources/js/pages/TimePage.vue @@ -0,0 +1,233 @@ + + + diff --git a/resources/js/pages/TimeSettingsPage.vue b/resources/js/pages/TimeSettingsPage.vue new file mode 100644 index 0000000..e16c9b9 --- /dev/null +++ b/resources/js/pages/TimeSettingsPage.vue @@ -0,0 +1,57 @@ + + + diff --git a/resources/js/registrations/time.ts b/resources/js/registrations/time.ts new file mode 100644 index 0000000..faeedc1 --- /dev/null +++ b/resources/js/registrations/time.ts @@ -0,0 +1,147 @@ +import { defineComponent, h } from 'vue' +import type { Component } from 'vue' +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' +import QuickStartOverlay from '@/components/QuickStartOverlay.vue' +import TimerChip from '@/components/TimerChip.vue' +import { timeMessages } from '@/messages/time' +import TimePage from '@/pages/TimePage.vue' +import TimeSettingsPage from '@/pages/TimeSettingsPage.vue' +import { refreshSession, resetSession, session, setAdminMode } from '@/stores/session' +import { resetTaskNames } from '@/stores/tasks' +import { timerStore } from '@/stores/timer' + +type NotifyType = 'success' | 'error' | 'warning' | 'info' + +const MODULE = 'tasks-projects' + +/** Where `registerPage` mounts the timesheet, for the links that lead to it. */ +const TIME_PATH = `/admin/modules/${MODULE}/time` + +/** + * Everything the time-tracking slice contributes to the host. + * + * Kept in one file so `init.ts` only ever gains a line per slice: the page, the + * header chip, the quick-start launcher, the settings page and the lifecycle + * wiring all start here. + * + * Nothing in this function talks to the network. Pinia is not installed when + * the boot callback runs, so the first read waits for `bootstrap:completed`, + * and every later company switch clears the previous company's answers before + * asking again. + */ +export function registerTimeTracking(extensions: InvoiceShelfExtensionApi): void { + extensions.addMessages(timeMessages) + + const notify = (type: NotifyType, message: string): void => { + extensions.notify(type, message) + } + + const openTimesheet = (): void => { + void extensions.router.push(TIME_PATH) + } + + extensions.registerPage({ + id: 'time', + module: MODULE, + path: 'time', + component: injected(extensions, TimePage), + meta: { + ability: `${MODULE}:view-own-time`, + title: 'tasks_projects.time.title', + }, + }) + + extensions.registerHeaderAction({ + id: `${MODULE}.timer-chip`, + priority: 30, + visible: (): boolean => timerStore.running !== null, + component: defineComponent({ + setup: () => () => + h(TimerChip, { + client: extensions.client, + notify, + onOpen: openTimesheet, + }), + }), + }) + + extensions.registerCompanyLayoutOverlay({ + id: `${MODULE}.quick-start`, + component: defineComponent({ + setup: () => () => + h(QuickStartOverlay, { + // A company switch starts the launcher clean rather than carrying a + // half-typed search from the workspace the user just left. + key: session.companySession, + client: extensions.client, + notify, + enabled: !session.adminMode, + onOpenTimesheet: openTimesheet, + }), + }), + }) + + extensions.registerCompanySettingsPage({ + id: `${MODULE}.settings`, + title: 'tasks_projects.settings.title', + icon: 'ClockIcon', + path: MODULE, + priority: 70, + component: injected(extensions, TimeSettingsPage), + }) + + extensions.on('bootstrap:completed', ({ adminMode }) => { + void enter(extensions, adminMode) + }) + + extensions.on('company:changing', () => { + leave() + }) + + extensions.on('company:changed', ({ companyId }) => { + void enter(extensions, companyId === null) + }) +} + +/** Read the company's settings and the caller's running timer. */ +async function enter(extensions: InvoiceShelfExtensionApi, adminMode: boolean): Promise { + setAdminMode(adminMode) + + if (adminMode) { + leave() + + return + } + + await refreshSession(extensions.client) + await timerStore.refresh(extensions.client) +} + +/** Forget the previous company: its timer, its task names and its settings. */ +function leave(): void { + timerStore.reset() + resetTaskNames() + resetSession() +} + +/** + * Hand a page the host services it cannot reach on its own. + * + * The same wrapper `init.ts` uses for the projects page: a module bundle runs + * on the host's Vue instance but not on its Pinia or router injections, so the + * client, the notifier and the router arrive as props, and route params arrive + * as attrs because the host registers module pages with `props: true`. + */ +function injected(extensions: InvoiceShelfExtensionApi, page: Component): Component { + return defineComponent({ + setup: (_props, { attrs }) => () => + h(page, { + ...attrs, + client: extensions.client, + notify: (type: NotifyType, message: string): void => { + extensions.notify(type, message) + }, + router: extensions.router, + }), + }) +} From 4dc4cda60cb418981f2b8faadd0824ad0c821b5b Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:38:43 +0200 Subject: [PATCH 4/6] build: compile the time tracking assets The package ships compiled, so `dist/` is committed with the source change and installs without a build step on the target system. It will be rebuilt when this branch merges with the board work. --- dist/init.js | 2569 ++++++++++++++++++++++++++++++++++++++++++------ dist/style.css | 2 +- 2 files changed, 2292 insertions(+), 279 deletions(-) diff --git a/dist/init.js b/dist/init.js index e7630bf..9fbd7b8 100644 --- a/dist/init.js +++ b/dist/init.js @@ -1,6 +1,6 @@ -const { Fragment: e, computed: t, createBlock: n, createCommentVNode: r, createElementBlock: i, createElementVNode: a, createTextVNode: o, createVNode: s, defineComponent: c, getCurrentInstance: l, h: u, normalizeClass: d, normalizeStyle: f, onBeforeUnmount: p, openBlock: m, reactive: h, ref: g, renderList: _, resolveComponent: v, toDisplayString: y, unref: b, vShow: x, watch: S, withCtx: C, withDirectives: w, withModifiers: T } = window.__invoiceshelf_vue; +const { Fragment: e, Teleport: t, computed: n, createBlock: r, createCommentVNode: i, createElementBlock: a, createElementVNode: o, createTextVNode: s, createVNode: c, defineComponent: l, getCurrentInstance: u, h: d, normalizeClass: f, normalizeStyle: p, onBeforeUnmount: m, onMounted: h, openBlock: g, reactive: _, ref: v, renderList: y, resolveComponent: b, toDisplayString: x, unref: S, vModelText: C, vShow: w, watch: T, withCtx: E, withDirectives: D, withKeys: O, withModifiers: k } = window.__invoiceshelf_vue; //#region resources/js/messages.ts -var E = { en: { tasks_projects: { +var A = { en: { tasks_projects: { general: { home: "Home", filter: "Filter", @@ -61,75 +61,75 @@ var E = { en: { tasks_projects: { delete_failed: "Unable to delete the project.", customers_failed: "Unable to load the customers." } -} } }, D = "/api/v1/tasks-projects", O = { - projects: `${D}/projects`, - project: (e) => `${D}/projects/${e}`, - archiveProject: (e) => `${D}/projects/${e}/archive`, - unarchiveProject: (e) => `${D}/projects/${e}/unarchive`, - members: `${D}/members`, - settings: `${D}/settings` -}, k = { customers: "/api/v1/customers" }; -async function A(e, t) { - let { data: n } = await e.get(O.projects, { params: t }); +} } }, j = "/api/v1/tasks-projects", M = { + projects: `${j}/projects`, + project: (e) => `${j}/projects/${e}`, + archiveProject: (e) => `${j}/projects/${e}/archive`, + unarchiveProject: (e) => `${j}/projects/${e}/unarchive`, + members: `${j}/members`, + settings: `${j}/settings` +}, N = { customers: "/api/v1/customers" }; +async function P(e, t) { + let { data: n } = await e.get(M.projects, { params: t }); return n; } -async function j(e, t) { - let { data: n } = await e.post(O.projects, t); +async function F(e, t) { + let { data: n } = await e.post(M.projects, t); return n.data; } -async function M(e, t, n) { - let { data: r } = await e.put(O.project(t), n); +async function I(e, t, n) { + let { data: r } = await e.put(M.project(t), n); return r.data; } -async function N(e, t) { - let { data: n } = await e.post(O.archiveProject(t)); +async function L(e, t) { + let { data: n } = await e.post(M.archiveProject(t)); return n.data; } -async function P(e, t) { - let { data: n } = await e.post(O.unarchiveProject(t)); +async function R(e, t) { + let { data: n } = await e.post(M.unarchiveProject(t)); return n.data; } -async function F(e, t) { - await e.delete(O.project(t)); +async function z(e, t) { + await e.delete(M.project(t)); } -async function I(e, t = 100) { - let { data: n } = await e.get(k.customers, { params: { limit: t } }); +async function B(e, t = 100) { + let { data: n } = await e.get(N.customers, { params: { limit: t } }); return n.data; } //#endregion //#region resources/js/support/errors.ts -function L(e) { +function ee(e) { if (typeof e != "object" || !e) return null; let t = e.response; return typeof t?.data != "object" || t.data === null ? null : t.data; } -function R(e, t) { - let n = L(e)?.message; +function V(e, t) { + let n = ee(e)?.message; return typeof n == "string" && n !== "" ? n : t; } -function z(e) { - let t = L(e)?.errors, n = {}; +function H(e) { + let t = ee(e)?.errors, n = {}; if (typeof t != "object" || !t) return n; for (let [e, r] of Object.entries(t)) Array.isArray(r) && typeof r[0] == "string" && (n[e] = r[0]); return n; } //#endregion //#region resources/js/support/format.ts -function B(e) { +function te(e) { return e === null ? "" : String(e / 100); } -function V(e) { +function ne(e) { let t = Number(e); return e.trim() === "" || Number.isNaN(t) ? null : Math.round(t * 100); } -function H(e) { +function re(e) { return e === null ? "" : String(e / 60); } -function U(e) { +function ie(e) { let t = Number(e); return e.trim() === "" || Number.isNaN(t) ? null : Math.round(t * 60); } -function W(e) { +function ae(e) { if (!e) return ""; let [t, n, r] = e.slice(0, 10).split("-").map(Number); return !t || !n || !r ? e : new Date(Date.UTC(t, n - 1, r)).toLocaleDateString(void 0, { @@ -139,19 +139,19 @@ function W(e) { timeZone: "UTC" }); } -function G(e) { +function oe(e) { if (typeof e == "string") return e.slice(0, 10); let t = String(e.getMonth() + 1).padStart(2, "0"), n = String(e.getDate()).padStart(2, "0"); return `${e.getFullYear()}-${t}-${n}`; } //#endregion //#region resources/js/support/i18n.ts -function K() { - return l()?.appContext.config.globalProperties.$t ?? ((e) => e); +function U() { + return u()?.appContext.config.globalProperties.$t ?? ((e) => e); } //#endregion //#region resources/js/components/ProjectFormModal.vue?vue&type=script&setup=true&lang.ts -var q = { class: "flex w-full items-center justify-between" }, J = { class: "space-y-5 px-6 py-6" }, Y = { class: "flex flex-wrap items-center gap-2" }, X = ["aria-label", "onClick"], ee = { class: "flex justify-end space-x-3 border-t border-line-default px-6 py-4" }, te = /* @__PURE__ */ c({ +var se = { class: "flex w-full items-center justify-between" }, ce = { class: "space-y-5 px-6 py-6" }, le = { class: "flex flex-wrap items-center gap-2" }, ue = ["aria-label", "onClick"], de = { class: "flex justify-end space-x-3 border-t border-line-default px-6 py-4" }, fe = /* @__PURE__ */ l({ __name: "ProjectFormModal", props: { show: { type: Boolean }, @@ -160,8 +160,8 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa project: {} }, emits: ["close", "saved"], - setup(r, { emit: c }) { - let l = r, u = c, p = [ + setup(t, { emit: i }) { + let l = t, u = i, d = [ "#2563eb", "#0891b2", "#059669", @@ -170,7 +170,7 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa "#dc2626", "#7c3aed", "#64748b" - ], x = K(), w = h({ + ], m = U(), h = _({ name: "", identifier: "", description: "", @@ -178,99 +178,99 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa defaultRate: "", budgetHours: "", dueDate: "" - }), E = g(null), D = g([]), O = g(!1), k = g({}), A = g(!1), N = t(() => l.project !== null), P = t(() => N.value ? x("tasks_projects.projects.edit_project") : x("tasks_projects.projects.new_project")); - S(() => l.show, (e) => { - e && (F(), te()); + }), C = v(null), w = v([]), D = v(!1), O = v({}), A = v(!1), j = n(() => l.project !== null), M = n(() => j.value ? m("tasks_projects.projects.edit_project") : m("tasks_projects.projects.new_project")); + T(() => l.show, (e) => { + e && (N(), R()); }, { immediate: !0 }); - function F() { + function N() { let e = l.project; - w.name = e?.name ?? "", w.identifier = e?.identifier ?? "", w.description = e?.description ?? "", w.colour = e?.colour ?? "", w.defaultRate = B(e?.default_rate ?? null), w.budgetHours = H(e?.budget_minutes ?? null), w.dueDate = e?.due_date ?? "", k.value = {}, E.value = L(e?.customer_id ?? null); + h.name = e?.name ?? "", h.identifier = e?.identifier ?? "", h.description = e?.description ?? "", h.colour = e?.colour ?? "", h.defaultRate = te(e?.default_rate ?? null), h.budgetHours = re(e?.budget_minutes ?? null), h.dueDate = e?.due_date ?? "", O.value = {}, C.value = P(e?.customer_id ?? null); } - function L(e) { - return e === null ? null : D.value.find((t) => t.id === e) ?? null; + function P(e) { + return e === null ? null : w.value.find((t) => t.id === e) ?? null; } - function W(e) { + function L(e) { return e.display_name || e.name || `#${e.id}`; } - async function te() { - if (!O.value) try { - let e = await I(l.client); - D.value = e.map((e) => ({ + async function R() { + if (!D.value) try { + let e = await B(l.client); + w.value = e.map((e) => ({ id: e.id, - label: W(e) - })), O.value = !0, E.value = L(l.project?.customer_id ?? null); + label: L(e) + })), D.value = !0, C.value = P(l.project?.customer_id ?? null); } catch (e) { - l.notify("error", R(e, x("tasks_projects.projects.customers_failed"))); + l.notify("error", V(e, m("tasks_projects.projects.customers_failed"))); } } - function Z() { + function z() { return { - name: w.name.trim(), - customer_id: E.value?.id ?? null, - identifier: w.identifier.trim() || null, - description: w.description.trim() || null, - colour: w.colour || null, - default_rate: V(w.defaultRate), - budget_minutes: U(w.budgetHours), - due_date: w.dueDate || null + name: h.name.trim(), + customer_id: C.value?.id ?? null, + identifier: h.identifier.trim() || null, + description: h.description.trim() || null, + colour: h.colour || null, + default_rate: ne(h.defaultRate), + budget_minutes: ie(h.budgetHours), + due_date: h.dueDate || null }; } - function ne(e) { - w.dueDate = e ? G(e) : ""; + function ee(e) { + h.dueDate = e ? oe(e) : ""; } - async function re() { + async function ae() { if (!A.value) { - if (w.name.trim() === "") { - k.value = { name: x("tasks_projects.projects.name_required") }; + if (h.name.trim() === "") { + O.value = { name: m("tasks_projects.projects.name_required") }; return; } - A.value = !0, k.value = {}; + A.value = !0, O.value = {}; try { - let e = l.project, t = e ? await M(l.client, e.id, Z()) : await j(l.client, Z()); + let e = l.project, t = e ? await I(l.client, e.id, z()) : await F(l.client, z()); u("saved", t); } catch (e) { - k.value = z(e), l.notify("error", R(e, x("tasks_projects.projects.save_failed"))); + O.value = H(e), l.notify("error", V(e, m("tasks_projects.projects.save_failed"))); } finally { A.value = !1; } } } - return (t, c) => { - let l = v("BaseIcon"), h = v("BaseInput"), g = v("BaseInputGroup"), S = v("BaseSelectInput"), O = v("BaseDatePicker"), j = v("BaseInputGrid"), M = v("BaseTextarea"), F = v("BaseButton"), I = v("BaseModal"); - return m(), n(I, { - show: r.show, - onClose: c[9] ||= (e) => u("close") + return (n, i) => { + let l = b("BaseIcon"), _ = b("BaseInput"), v = b("BaseInputGroup"), T = b("BaseSelectInput"), D = b("BaseDatePicker"), N = b("BaseInputGrid"), P = b("BaseTextarea"), F = b("BaseButton"), I = b("BaseModal"); + return g(), r(I, { + show: t.show, + onClose: i[9] ||= (e) => u("close") }, { - header: C(() => [a("div", q, [a("span", null, y(P.value), 1), s(l, { + header: E(() => [o("div", se, [o("span", null, x(M.value), 1), c(l, { name: "XMarkIcon", class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", - onClick: c[0] ||= (e) => u("close") + onClick: i[0] ||= (e) => u("close") })])]), - default: C(() => [a("form", { onSubmit: T(re, ["prevent"]) }, [a("div", J, [ - s(j, null, { - default: C(() => [ - s(g, { - label: b(x)("tasks_projects.projects.fields.name"), - error: k.value.name, + default: E(() => [o("form", { onSubmit: k(ae, ["prevent"]) }, [o("div", ce, [ + c(N, null, { + default: E(() => [ + c(v, { + label: S(m)("tasks_projects.projects.fields.name"), + error: O.value.name, required: "" }, { - default: C(() => [s(h, { - modelValue: w.name, - "onUpdate:modelValue": c[1] ||= (e) => w.name = e, - invalid: !!k.value.name, + default: E(() => [c(_, { + modelValue: h.name, + "onUpdate:modelValue": i[1] ||= (e) => h.name = e, + invalid: !!O.value.name, type: "text" }, null, 8, ["modelValue", "invalid"])]), _: 1 }, 8, ["label", "error"]), - s(g, { - label: b(x)("tasks_projects.projects.fields.identifier"), - error: k.value.identifier, - "help-text": b(x)("tasks_projects.projects.fields.identifier_help") + c(v, { + label: S(m)("tasks_projects.projects.fields.identifier"), + error: O.value.identifier, + "help-text": S(m)("tasks_projects.projects.fields.identifier_help") }, { - default: C(() => [s(h, { - modelValue: w.identifier, - "onUpdate:modelValue": c[2] ||= (e) => w.identifier = e, - invalid: !!k.value.identifier, + default: E(() => [c(_, { + modelValue: h.identifier, + "onUpdate:modelValue": i[2] ||= (e) => h.identifier = e, + invalid: !!O.value.identifier, type: "text", maxlength: "32" }, null, 8, ["modelValue", "invalid"])]), @@ -280,16 +280,16 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa "error", "help-text" ]), - s(g, { - label: b(x)("tasks_projects.projects.fields.customer"), - error: k.value.customer_id, - "help-text": b(x)("tasks_projects.projects.fields.customer_help") + c(v, { + label: S(m)("tasks_projects.projects.fields.customer"), + error: O.value.customer_id, + "help-text": S(m)("tasks_projects.projects.fields.customer_help") }, { - default: C(() => [s(S, { - modelValue: E.value, - "onUpdate:modelValue": c[3] ||= (e) => E.value = e, - options: D.value, - placeholder: b(x)("tasks_projects.projects.fields.customer_placeholder"), + default: E(() => [c(T, { + modelValue: C.value, + "onUpdate:modelValue": i[3] ||= (e) => C.value = e, + options: w.value, + placeholder: S(m)("tasks_projects.projects.fields.customer_placeholder"), "label-key": "label" }, null, 8, [ "modelValue", @@ -302,25 +302,25 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa "error", "help-text" ]), - s(g, { - label: b(x)("tasks_projects.projects.fields.due_date"), - error: k.value.due_date + c(v, { + label: S(m)("tasks_projects.projects.fields.due_date"), + error: O.value.due_date }, { - default: C(() => [s(O, { - "model-value": w.dueDate, - "onUpdate:modelValue": ne + default: E(() => [c(D, { + "model-value": h.dueDate, + "onUpdate:modelValue": ee }, null, 8, ["model-value"])]), _: 1 }, 8, ["label", "error"]), - s(g, { - label: b(x)("tasks_projects.projects.fields.default_rate"), - error: k.value.default_rate, - "help-text": b(x)("tasks_projects.projects.fields.default_rate_help") + c(v, { + label: S(m)("tasks_projects.projects.fields.default_rate"), + error: O.value.default_rate, + "help-text": S(m)("tasks_projects.projects.fields.default_rate_help") }, { - default: C(() => [s(h, { - modelValue: w.defaultRate, - "onUpdate:modelValue": c[4] ||= (e) => w.defaultRate = e, - invalid: !!k.value.default_rate, + default: E(() => [c(_, { + modelValue: h.defaultRate, + "onUpdate:modelValue": i[4] ||= (e) => h.defaultRate = e, + invalid: !!O.value.default_rate, type: "number", step: "0.01", min: "0" @@ -331,14 +331,14 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa "error", "help-text" ]), - s(g, { - label: b(x)("tasks_projects.projects.fields.budget_hours"), - error: k.value.budget_minutes + c(v, { + label: S(m)("tasks_projects.projects.fields.budget_hours"), + error: O.value.budget_minutes }, { - default: C(() => [s(h, { - modelValue: w.budgetHours, - "onUpdate:modelValue": c[5] ||= (e) => w.budgetHours = e, - invalid: !!k.value.budget_minutes, + default: E(() => [c(_, { + modelValue: h.budgetHours, + "onUpdate:modelValue": i[5] ||= (e) => h.budgetHours = e, + invalid: !!O.value.budget_minutes, type: "number", step: "0.25", min: "0" @@ -348,69 +348,69 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa ]), _: 1 }), - s(g, { - label: b(x)("tasks_projects.projects.fields.colour"), - error: k.value.colour + c(v, { + label: S(m)("tasks_projects.projects.fields.colour"), + error: O.value.colour }, { - default: C(() => [a("div", Y, [(m(), i(e, null, _(p, (e) => a("button", { + default: E(() => [o("div", le, [(g(), a(e, null, y(d, (e) => o("button", { key: e, type: "button", - class: d(["h-7 w-7 rounded-full border-2 transition", w.colour === e ? "border-heading" : "border-line-default"]), - style: f({ backgroundColor: e }), + class: f(["h-7 w-7 rounded-full border-2 transition", h.colour === e ? "border-heading" : "border-line-default"]), + style: p({ backgroundColor: e }), "aria-label": e, - onClick: (t) => w.colour = w.colour === e ? "" : e - }, null, 14, X)), 64)), a("button", { + onClick: (t) => h.colour = h.colour === e ? "" : e + }, null, 14, ue)), 64)), o("button", { type: "button", class: "rounded-md border border-line-default px-2 py-1 text-xs text-muted hover:bg-hover", - onClick: c[6] ||= (e) => w.colour = "" - }, y(b(x)("tasks_projects.projects.fields.colour_none")), 1)])]), + onClick: i[6] ||= (e) => h.colour = "" + }, x(S(m)("tasks_projects.projects.fields.colour_none")), 1)])]), _: 1 }, 8, ["label", "error"]), - s(g, { - label: b(x)("tasks_projects.projects.fields.description"), - error: k.value.description + c(v, { + label: S(m)("tasks_projects.projects.fields.description"), + error: O.value.description }, { - default: C(() => [s(M, { - modelValue: w.description, - "onUpdate:modelValue": c[7] ||= (e) => w.description = e, + default: E(() => [c(P, { + modelValue: h.description, + "onUpdate:modelValue": i[7] ||= (e) => h.description = e, row: 3, - invalid: !!k.value.description + invalid: !!O.value.description }, null, 8, ["modelValue", "invalid"])]), _: 1 }, 8, ["label", "error"]) - ]), a("div", ee, [s(F, { + ]), o("div", de, [c(F, { type: "button", variant: "primary-outline", - onClick: c[8] ||= (e) => u("close") + onClick: i[8] ||= (e) => u("close") }, { - default: C(() => [o(y(b(x)("tasks_projects.general.cancel")), 1)]), + default: E(() => [s(x(S(m)("tasks_projects.general.cancel")), 1)]), _: 1 - }), s(F, { + }), c(F, { type: "submit", variant: "primary", loading: A.value, disabled: A.value }, { - default: C(() => [o(y(N.value ? b(x)("tasks_projects.general.update") : b(x)("tasks_projects.general.save")), 1)]), + default: E(() => [s(x(j.value ? S(m)("tasks_projects.general.update") : S(m)("tasks_projects.general.save")), 1)]), _: 1 }, 8, ["loading", "disabled"])])], 32)]), _: 1 }, 8, ["show"]); }; } -}), Z = { class: "flex items-center justify-end space-x-5" }, ne = { class: "relative table-container" }, re = { class: "flex items-center" }, ie = { +}), pe = { class: "flex items-center justify-end space-x-5" }, me = { class: "relative table-container" }, he = { class: "flex items-center" }, ge = { key: 0, class: "block text-xs font-normal text-muted" -}, ae = { key: 0 }, oe = { +}, _e = { key: 0 }, ve = { key: 1, class: "text-subtle" -}, se = { +}, ye = { key: 1, class: "text-subtle" -}, ce = { key: 0 }, le = { +}, be = { key: 0 }, xe = { key: 1, class: "text-subtle" -}, Q = 10, ue = 350, de = /* @__PURE__ */ c({ +}, Se = 10, Ce = 350, we = /* @__PURE__ */ l({ __name: "ProjectsIndexPage", props: { client: { type: [Function, Object] }, @@ -418,10 +418,10 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa router: {} }, setup(e) { - let c = e, l = K(), u = g(null), _ = g(!1), T = g(!0), E = g(0), D = g(!1), O = g(null), k = g(null), j = h({ + let t = e, l = U(), u = v(null), d = v(!1), h = v(!0), y = v(0), C = v(!1), O = v(null), k = v(null), A = _({ search: "", status: "ACTIVE" - }), M = t(() => [ + }), j = n(() => [ { id: "ACTIVE", label: l("tasks_projects.projects.status.active") @@ -434,12 +434,12 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa id: "ALL", label: l("tasks_projects.projects.status.all") } - ]), I = t({ - get: () => M.value.find((e) => e.id === j.status) ?? M.value[0], + ]), M = n({ + get: () => j.value.find((e) => e.id === A.status) ?? j.value[0], set: (e) => { - j.status = e.id; + A.status = e.id; } - }), L = t(() => [ + }), N = n(() => [ { key: "name", label: l("tasks_projects.projects.columns.name"), @@ -473,19 +473,19 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa sortable: !1, tdClass: "text-right text-sm font-medium" } - ]), z = t(() => j.search.trim() !== "" || j.status !== "ACTIVE"), B = t(() => !T.value && E.value === 0 && !z.value), V; - S(() => j.search, () => { - clearTimeout(V), V = setTimeout(() => U(), ue); - }), S(() => j.status, () => U()), p(() => clearTimeout(V)); - async function H({ page: e }) { - let t = { + ]), F = n(() => A.search.trim() !== "" || A.status !== "ACTIVE"), I = n(() => !h.value && y.value === 0 && !F.value), B; + T(() => A.search, () => { + clearTimeout(B), B = setTimeout(() => H(), Ce); + }), T(() => A.status, () => H()), m(() => clearTimeout(B)); + async function ee({ page: e }) { + let n = { page: e, - limit: Q + limit: Se }; - j.status !== "ALL" && (t.status = j.status), j.search.trim() !== "" && (t.search = j.search.trim()), T.value = !0; + A.status !== "ALL" && (n.status = A.status), A.search.trim() !== "" && (n.search = A.search.trim()), h.value = !0; try { - let e = await A(c.client, t); - return E.value = e.meta.total, { + let e = await P(t.client, n); + return y.value = e.meta.total, { data: e.data, pagination: { totalPages: e.meta.last_page, @@ -495,103 +495,103 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa } }; } catch (e) { - return c.notify("error", R(e, l("tasks_projects.projects.load_failed"))), { + return t.notify("error", V(e, l("tasks_projects.projects.load_failed"))), { data: [], pagination: { totalPages: 1, currentPage: 1, totalCount: 0, - limit: Q + limit: Se } }; } finally { - T.value = !1; + h.value = !1; } } - function U(e = !1) { + function H(e = !1) { u.value?.refresh(e); } - function G() { - _.value && q(), _.value = !_.value; + function te() { + d.value && ne(), d.value = !d.value; } - function q() { - j.search = "", j.status = "ACTIVE"; + function ne() { + A.search = "", A.status = "ACTIVE"; } - function J() { - O.value = null, D.value = !0; + function re() { + O.value = null, C.value = !0; } - function Y(e) { - O.value = e, D.value = !0; + function ie(e) { + O.value = e, C.value = !0; } - function X(e) { - let t = O.value ? l("tasks_projects.projects.updated", { name: e.name }) : l("tasks_projects.projects.created", { name: e.name }); - D.value = !1, O.value = null, c.notify("success", t), U(); + function oe(e) { + let n = O.value ? l("tasks_projects.projects.updated", { name: e.name }) : l("tasks_projects.projects.created", { name: e.name }); + C.value = !1, O.value = null, t.notify("success", n), H(); } - async function ee(e) { + async function se(e) { k.value = e.id; try { - e.status === "ARCHIVED" ? (await P(c.client, e.id), c.notify("success", l("tasks_projects.projects.unarchived", { name: e.name }))) : (await N(c.client, e.id), c.notify("success", l("tasks_projects.projects.archived", { name: e.name }))), U(!0); + e.status === "ARCHIVED" ? (await R(t.client, e.id), t.notify("success", l("tasks_projects.projects.unarchived", { name: e.name }))) : (await L(t.client, e.id), t.notify("success", l("tasks_projects.projects.archived", { name: e.name }))), H(!0); } catch (e) { - c.notify("error", R(e, l("tasks_projects.projects.save_failed"))); + t.notify("error", V(e, l("tasks_projects.projects.save_failed"))); } finally { k.value = null; } } - async function de(e) { + async function ce(e) { if (window.confirm(l("tasks_projects.projects.delete_confirm", { name: e.name }))) { k.value = e.id; try { - await F(c.client, e.id), c.notify("success", l("tasks_projects.projects.deleted", { name: e.name })), U(!0); + await z(t.client, e.id), t.notify("success", l("tasks_projects.projects.deleted", { name: e.name })), H(!0); } catch (e) { - c.notify("error", R(e, l("tasks_projects.projects.delete_failed"))); + t.notify("error", V(e, l("tasks_projects.projects.delete_failed"))); } finally { k.value = null; } } } - function $(e) { + function le(e) { return e === "ACTIVE" ? "bg-primary-50! text-primary-500!" : "bg-surface-tertiary! text-muted!"; } - function fe(e) { + function ue(e) { return l(e === "ACTIVE" ? "tasks_projects.projects.status.active" : "tasks_projects.projects.status.archived"); } - return (t, c) => { - let p = v("BaseBreadcrumbItem"), h = v("BaseBreadcrumb"), g = v("BaseIcon"), S = v("BaseButton"), T = v("BasePageHeader"), E = v("BaseInput"), A = v("BaseInputGroup"), N = v("BaseSelectInput"), P = v("BaseFilterWrapper"), F = v("BaseEmptyPlaceholder"), R = v("BaseBadge"), z = v("BaseFormatMoney"), V = v("BaseDropdownItem"), U = v("BaseDropdown"), K = v("BaseTable"), Q = v("BasePage"); - return m(), n(Q, null, { - default: C(() => [ - s(T, { title: b(l)("tasks_projects.projects.title") }, { - actions: C(() => [a("div", Z, [s(S, { + return (t, n) => { + let m = b("BaseBreadcrumbItem"), h = b("BaseBreadcrumb"), _ = b("BaseIcon"), v = b("BaseButton"), y = b("BasePageHeader"), T = b("BaseInput"), P = b("BaseInputGroup"), F = b("BaseSelectInput"), L = b("BaseFilterWrapper"), R = b("BaseEmptyPlaceholder"), z = b("BaseBadge"), B = b("BaseFormatMoney"), V = b("BaseDropdownItem"), H = b("BaseDropdown"), U = b("BaseTable"), de = b("BasePage"); + return g(), r(de, null, { + default: E(() => [ + c(y, { title: S(l)("tasks_projects.projects.title") }, { + actions: E(() => [o("div", pe, [c(v, { variant: "primary-outline", - onClick: G + onClick: te }, { - right: C((e) => [_.value ? (m(), n(g, { + right: E((e) => [d.value ? (g(), r(_, { key: 1, name: "XMarkIcon", - class: d(e.class) - }, null, 8, ["class"])) : (m(), n(g, { + class: f(e.class) + }, null, 8, ["class"])) : (g(), r(_, { key: 0, name: "FunnelIcon", - class: d(e.class) + class: f(e.class) }, null, 8, ["class"]))]), - default: C(() => [o(y(b(l)("tasks_projects.general.filter")) + " ", 1)]), + default: E(() => [s(x(S(l)("tasks_projects.general.filter")) + " ", 1)]), _: 1 - }), s(S, { + }), c(v, { variant: "primary", - onClick: J + onClick: re }, { - left: C((e) => [s(g, { + left: E((e) => [c(_, { name: "PlusIcon", - class: d(e.class) + class: f(e.class) }, null, 8, ["class"])]), - default: C(() => [o(" " + y(b(l)("tasks_projects.projects.new_project")), 1)]), + default: E(() => [s(" " + x(S(l)("tasks_projects.projects.new_project")), 1)]), _: 1 })])]), - default: C(() => [s(h, null, { - default: C(() => [s(p, { - title: b(l)("tasks_projects.general.home"), + default: E(() => [c(h, null, { + default: E(() => [c(m, { + title: S(l)("tasks_projects.general.home"), to: "/admin/dashboard" - }, null, 8, ["title"]), s(p, { - title: b(l)("tasks_projects.projects.title"), + }, null, 8, ["title"]), c(m, { + title: S(l)("tasks_projects.projects.title"), to: "#", active: "" }, null, 8, ["title"])]), @@ -599,119 +599,119 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa })]), _: 1 }, 8, ["title"]), - s(P, { - show: _.value, + c(L, { + show: d.value, class: "mt-3", - onClear: q + onClear: ne }, { - default: C(() => [s(A, { - label: b(l)("tasks_projects.general.search"), + default: E(() => [c(P, { + label: S(l)("tasks_projects.general.search"), class: "mt-2 flex-1" }, { - default: C(() => [s(E, { - modelValue: j.search, - "onUpdate:modelValue": c[0] ||= (e) => j.search = e, + default: E(() => [c(T, { + modelValue: A.search, + "onUpdate:modelValue": n[0] ||= (e) => A.search = e, type: "text", name: "search", autocomplete: "off", - placeholder: b(l)("tasks_projects.projects.search_placeholder") + placeholder: S(l)("tasks_projects.projects.search_placeholder") }, null, 8, ["modelValue", "placeholder"])]), _: 1 - }, 8, ["label"]), s(A, { - label: b(l)("tasks_projects.projects.columns.status"), + }, 8, ["label"]), c(P, { + label: S(l)("tasks_projects.projects.columns.status"), class: "mt-2 flex-1" }, { - default: C(() => [s(N, { - modelValue: I.value, - "onUpdate:modelValue": c[1] ||= (e) => I.value = e, - options: M.value, + default: E(() => [c(F, { + modelValue: M.value, + "onUpdate:modelValue": n[1] ||= (e) => M.value = e, + options: j.value, "label-key": "label" }, null, 8, ["modelValue", "options"])]), _: 1 }, 8, ["label"])]), _: 1 }, 8, ["show"]), - w(s(F, { - title: b(l)("tasks_projects.projects.empty_title"), - description: b(l)("tasks_projects.projects.empty_description") + D(c(R, { + title: S(l)("tasks_projects.projects.empty_title"), + description: S(l)("tasks_projects.projects.empty_description") }, { - actions: C(() => [s(S, { + actions: E(() => [c(v, { variant: "primary", - onClick: J + onClick: re }, { - left: C((e) => [s(g, { + left: E((e) => [c(_, { name: "PlusIcon", - class: d(e.class) + class: f(e.class) }, null, 8, ["class"])]), - default: C(() => [o(" " + y(b(l)("tasks_projects.projects.new_project")), 1)]), + default: E(() => [s(" " + x(S(l)("tasks_projects.projects.new_project")), 1)]), _: 1 })]), - default: C(() => [s(g, { + default: E(() => [c(_, { name: "FolderIcon", class: "mt-5 mb-4 h-16 w-16 text-subtle" })]), _: 1 - }, 8, ["title", "description"]), [[x, B.value]]), - w(a("div", ne, [s(K, { + }, 8, ["title", "description"]), [[w, I.value]]), + D(o("div", me, [c(U, { ref_key: "tableRef", ref: u, - data: H, - columns: L.value, + data: ee, + columns: N.value, class: "mt-3" }, { - "cell-name": C(({ row: e }) => [a("div", re, [a("span", { - class: d(["mr-3 inline-block h-2.5 w-2.5 shrink-0 rounded-full", e.data.colour ? "" : "bg-line-default"]), - style: f(e.data.colour ? { backgroundColor: e.data.colour } : void 0) - }, null, 6), a("span", null, [o(y(e.data.name) + " ", 1), e.data.identifier ? (m(), i("span", ie, y(e.data.identifier), 1)) : r("", !0)])])]), - "cell-status": C(({ row: e }) => [s(R, { class: d(["rounded-full", $(e.data.status)]) }, { - default: C(() => [o(y(fe(e.data.status)), 1)]), + "cell-name": E(({ row: e }) => [o("div", he, [o("span", { + class: f(["mr-3 inline-block h-2.5 w-2.5 shrink-0 rounded-full", e.data.colour ? "" : "bg-line-default"]), + style: p(e.data.colour ? { backgroundColor: e.data.colour } : void 0) + }, null, 6), o("span", null, [s(x(e.data.name) + " ", 1), e.data.identifier ? (g(), a("span", ge, x(e.data.identifier), 1)) : i("", !0)])])]), + "cell-status": E(({ row: e }) => [c(z, { class: f(["rounded-full", le(e.data.status)]) }, { + default: E(() => [s(x(ue(e.data.status)), 1)]), _: 2 }, 1032, ["class"])]), - "cell-customer": C(({ row: e }) => [e.data.customer_id ? (m(), i("span", ae, "#" + y(e.data.customer_id), 1)) : (m(), i("span", oe, y(b(l)("tasks_projects.projects.internal")), 1))]), - "cell-default_rate": C(({ row: e }) => [e.data.default_rate === null ? (m(), i("span", se, "-")) : (m(), n(z, { + "cell-customer": E(({ row: e }) => [e.data.customer_id ? (g(), a("span", _e, "#" + x(e.data.customer_id), 1)) : (g(), a("span", ve, x(S(l)("tasks_projects.projects.internal")), 1))]), + "cell-default_rate": E(({ row: e }) => [e.data.default_rate === null ? (g(), a("span", ye, "-")) : (g(), r(B, { key: 0, amount: e.data.default_rate }, null, 8, ["amount"]))]), - "cell-due_date": C(({ row: e }) => [e.data.due_date ? (m(), i("span", ce, y(b(W)(e.data.due_date)), 1)) : (m(), i("span", le, "-"))]), - "cell-actions": C(({ row: e }) => [s(U, { "content-loading": k.value === e.data.id }, { - activator: C(() => [s(g, { + "cell-due_date": E(({ row: e }) => [e.data.due_date ? (g(), a("span", be, x(S(ae)(e.data.due_date)), 1)) : (g(), a("span", xe, "-"))]), + "cell-actions": E(({ row: e }) => [c(H, { "content-loading": k.value === e.data.id }, { + activator: E(() => [c(_, { name: "EllipsisHorizontalIcon", class: "h-5 text-muted" })]), - default: C(() => [ - s(V, { onClick: (t) => Y(e.data) }, { - default: C(() => [s(g, { + default: E(() => [ + c(V, { onClick: (t) => ie(e.data) }, { + default: E(() => [c(_, { name: "PencilIcon", class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" - }), o(" " + y(b(l)("tasks_projects.general.edit")), 1)]), + }), s(" " + x(S(l)("tasks_projects.general.edit")), 1)]), _: 1 }, 8, ["onClick"]), - s(V, { onClick: (t) => ee(e.data) }, { - default: C(() => [s(g, { + c(V, { onClick: (t) => se(e.data) }, { + default: E(() => [c(_, { name: e.data.status === "ARCHIVED" ? "ArrowPathIcon" : "ArchiveBoxIcon", class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" - }, null, 8, ["name"]), o(" " + y(e.data.status === "ARCHIVED" ? b(l)("tasks_projects.projects.unarchive") : b(l)("tasks_projects.projects.archive")), 1)]), + }, null, 8, ["name"]), s(" " + x(e.data.status === "ARCHIVED" ? S(l)("tasks_projects.projects.unarchive") : S(l)("tasks_projects.projects.archive")), 1)]), _: 2 }, 1032, ["onClick"]), - s(V, { onClick: (t) => de(e.data) }, { - default: C(() => [s(g, { + c(V, { onClick: (t) => ce(e.data) }, { + default: E(() => [c(_, { name: "TrashIcon", class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" - }), o(" " + y(b(l)("tasks_projects.general.delete")), 1)]), + }), s(" " + x(S(l)("tasks_projects.general.delete")), 1)]), _: 1 }, 8, ["onClick"]) ]), _: 2 }, 1032, ["content-loading"])]), _: 1 - }, 8, ["columns"])], 512), [[x, !B.value]]), - s(te, { - show: D.value, + }, 8, ["columns"])], 512), [[w, !I.value]]), + c(fe, { + show: C.value, client: e.client, notify: e.notify, project: O.value, - onClose: c[2] ||= (e) => D.value = !1, - onSaved: X + onClose: n[2] ||= (e) => C.value = !1, + onSaved: oe }, null, 8, [ "show", "client", @@ -723,21 +723,2034 @@ var q = { class: "flex w-full items-center justify-between" }, J = { class: "spa }); }; } -}), $ = "tasks-projects"; +}), W = "/api/v1/tasks-projects", G = { + timeEntries: `${W}/time-entries`, + timeEntry: (e) => `${W}/time-entries/${e}`, + timer: `${W}/timer`, + timerStart: `${W}/timer/start`, + timerStop: `${W}/timer/stop`, + taskStatuses: `${W}/task-statuses`, + taskStatus: (e) => `${W}/task-statuses/${e}`, + reorderTaskStatuses: `${W}/task-statuses/reorder`, + tasks: `${W}/tasks`, + task: (e) => `${W}/tasks/${e}`, + members: `${W}/members`, + settings: `${W}/settings` +}, Te = { bootstrap: "/api/v1/bootstrap" }, Ee = 100, De = 5, Oe = 10; +async function ke(e, t) { + let { data: n } = await e.get(G.timeEntries, { params: t }); + return n; +} +async function Ae(e, t) { + let n = []; + for (let r = 1; r <= De; r += 1) { + let i = await ke(e, { + ...t, + page: r, + limit: Ee + }); + if (n.push(...i.data ?? []), !i.meta || r >= i.meta.last_page) break; + } + return n; +} +async function je(e, t) { + let { data: n } = await e.post(G.timeEntries, t); + return n.data; +} +async function Me(e, t, n) { + let { data: r } = await e.put(G.timeEntry(t), n); + return r.data; +} +async function Ne(e, t) { + await e.delete(G.timeEntry(t)); +} +async function Pe(e) { + let { data: t } = await e.get(G.timer); + return t?.data ?? null; +} +async function Fe(e, t) { + let { data: n } = await e.post(G.timerStart, t); + return n.data; +} +async function Ie(e) { + let { data: t } = await e.post(G.timerStop); + return t.data; +} +async function Le(e) { + await e.delete(G.timer); +} +async function Re(e) { + let { data: t } = await e.get(G.taskStatuses); + return t.data ?? []; +} +async function ze(e, t) { + let { data: n } = await e.post(G.taskStatuses, t); + return n.data; +} +async function Be(e, t, n) { + let { data: r } = await e.put(G.taskStatus(t), n); + return r.data; +} +async function Ve(e, t) { + await e.delete(G.taskStatus(t)); +} +async function He(e, t) { + let { data: n } = await e.post(G.reorderTaskStatuses, { ids: t }); + return n.data ?? []; +} +async function Ue(e, t, n = Oe) { + let r = { limit: n }; + t.trim() !== "" && (r.search = t.trim()); + let { data: i } = await e.get(G.tasks, { params: r }); + return i.data ?? []; +} +async function We(e, t) { + let { data: n } = await e.get(G.task(t)); + return n.data; +} +async function Ge(e) { + let { data: t } = await e.get(G.members); + return t.data ?? []; +} +async function Ke(e) { + let { data: t } = await e.get(G.settings); + return t.data; +} +async function qe(e) { + let { data: t } = await e.get(Te.bootstrap), n = t?.current_user?.id; + return typeof n == "number" ? n : null; +} +//#endregion +//#region resources/js/stores/tasks.ts +var Je = _({}), Ye = /* @__PURE__ */ new Set(), Xe = 5; +function Ze(e) { + return e === null ? "" : Je[e] ?? `#${e}`; +} +function Qe(e) { + e && typeof e.id == "number" && typeof e.name == "string" && (Je[e.id] = e.name); +} +async function $e(e, t) { + let n = [...new Set(t)].filter((e) => typeof e == "number" && Je[e] === void 0 && !Ye.has(e)); + for (let e of n) Ye.add(e); + for (let t = 0; t < n.length; t += Xe) await Promise.all(n.slice(t, t + Xe).map(async (t) => { + try { + Qe(await We(e, t)); + } catch {} finally { + Ye.delete(t); + } + })); +} +function et() { + for (let e of Object.keys(Je)) delete Je[Number(e)]; + Ye.clear(); +} +//#endregion +//#region resources/js/support/http.ts +function tt(e) { + if (typeof e != "object" || !e) return null; + let t = e.response?.status; + return typeof t == "number" ? t : null; +} +function nt(e) { + return tt(e) === 409; +} +function rt(e) { + return tt(e) === 403; +} +//#endregion +//#region resources/js/support/time.ts +var it = 60, at = 60, ot = 7; +function st(e) { + let t = Number.isFinite(e) && e > 0 ? Math.floor(e) : 0, n = Math.floor(t / 3600), r = Math.floor(t % 3600 / at), i = t % at; + return `${n}:${J(r)}:${J(i)}`; +} +function K(e) { + let t = e !== null && Number.isFinite(e) && e > 0 ? Math.round(e) : 0; + return `${Math.floor(t / it)}:${J(t % it)}`; +} +function ct(e) { + let t = e.trim(); + if (t === "") return null; + let n = /^(\d+):([0-5]?\d)$/.exec(t); + if (n) return Number(n[1]) * it + Number(n[2]); + if (!/^\d+([.,]\d+)?$/.test(t)) return null; + let r = Number(t.replace(",", ".")); + return Number.isNaN(r) ? null : Math.round(r * it); +} +function lt(e) { + let t = yt(e); + return t === null ? "" : q(t); +} +function ut(e) { + let t = yt(e); + return t === null ? "" : `${J(t.getHours())}:${J(t.getMinutes())}`; +} +function dt(e, t = "09:00") { + let n = bt(e), r = /^(\d{1,2}):([0-5]\d)$/.exec(t.trim()); + if (n === null || r === null) return null; + let i = Number(r[1]); + return i > 23 ? null : (n.setHours(i, Number(r[2]), 0, 0), n.toISOString()); +} +function ft(e, t) { + let n = new Date(e); + return n.setTime(n.getTime() + t * at * 1e3), n.toISOString(); +} +function pt(e, t) { + let n = Number.isInteger(t) && t >= 0 && t <= 6 ? t : 1, r = xt(e), i = (r.getDay() - n + ot) % ot; + return r.setDate(r.getDate() - i), r; +} +function mt(e) { + return Array.from({ length: ot }, (t, n) => ht(e, n)); +} +function ht(e, t) { + let n = xt(e); + return n.setDate(n.getDate() + t), n; +} +function q(e) { + return `${e.getFullYear()}-${J(e.getMonth() + 1)}-${J(e.getDate())}`; +} +function gt(e) { + return { + weekday: e.toLocaleDateString(void 0, { weekday: "short" }), + day: e.toLocaleDateString(void 0, { + day: "numeric", + month: "short" + }) + }; +} +function _t(e) { + return q(e) === q(/* @__PURE__ */ new Date()); +} +function vt(e) { + let t = yt(e); + return t === null ? 0 : Math.max(0, Math.floor((Date.now() - t.getTime()) / 1e3)); +} +function yt(e) { + if (!e) return null; + let t = new Date(e); + return Number.isNaN(t.getTime()) ? null : t; +} +function bt(e) { + let t = /^(\d{4})-(\d{2})-(\d{2})/.exec(e.trim()); + if (t === null) return null; + let n = new Date(Number(t[1]), Number(t[2]) - 1, Number(t[3]), 0, 0, 0, 0); + return Number.isNaN(n.getTime()) ? null : n; +} +function xt(e) { + let t = new Date(e.getTime()); + return t.setHours(0, 0, 0, 0), t; +} +function J(e) { + return String(e).padStart(2, "0"); +} +//#endregion +//#region resources/js/stores/timer.ts +var Y = _({ + running: null, + elapsedSeconds: 0, + busy: !1 +}), St; +function Ct() { + Y.elapsedSeconds = Y.running === null ? 0 : vt(Y.running.started_at); +} +function wt() { + Ct(), St === void 0 && (St = setInterval(Ct, 1e3)); +} +function Tt() { + St !== void 0 && (clearInterval(St), St = void 0), Y.elapsedSeconds = 0; +} +function Et(e, t) { + if (Y.running = e && typeof e.id == "number" ? e : null, Y.running === null) { + Tt(); + return; + } + wt(), t && typeof Y.running.task_id == "number" && $e(t, [Y.running.task_id]); +} +function Dt(e, t, n) { + e?.notify("error", V(t, e.t(n))); +} +var X = { + get running() { + return Y.running; + }, + get elapsedSeconds() { + return Y.elapsedSeconds; + }, + get busy() { + return Y.busy; + }, + async refresh(e) { + try { + Et(await Pe(e), e); + } catch { + Et(null); + } + }, + async start(e, t, n = null, r) { + if (Y.busy) return null; + Y.busy = !0; + try { + let r = await Fe(e, { + task_id: t, + description: n + }); + return Et(r, e), r; + } catch (t) { + return nt(t) ? (r?.notify("warning", r.t("tasks_projects.timer.already_running")), await this.refresh(e)) : Dt(r, t, "tasks_projects.timer.start_failed"), null; + } finally { + Y.busy = !1; + } + }, + async stop(e, t) { + if (Y.busy || Y.running === null) return null; + Y.busy = !0; + try { + let t = await Ie(e); + return Et(null), t; + } catch (n) { + return Dt(t, n, "tasks_projects.timer.stop_failed"), await this.refresh(e), null; + } finally { + Y.busy = !1; + } + }, + async discard(e, t) { + if (Y.busy || Y.running === null) return !1; + Y.busy = !0; + try { + return await Le(e), Et(null), !0; + } catch (n) { + return Dt(t, n, "tasks_projects.timer.discard_failed"), await this.refresh(e), !1; + } finally { + Y.busy = !1; + } + }, + reset() { + Y.busy = !1, Et(null); + } +}, Ot = { + key: 0, + class: "fixed right-6 bottom-20 z-40 flex flex-col items-end gap-3" +}, kt = ["aria-label"], At = { class: "flex items-center justify-between border-b border-line-default px-4 py-3" }, jt = { class: "text-sm font-semibold text-heading" }, Mt = ["aria-label"], Nt = { + key: 0, + class: "space-y-4 px-4 py-4" +}, Pt = { class: "truncate text-sm font-medium text-heading" }, Ft = { class: "mt-1 text-2xl font-semibold tabular-nums text-primary-500" }, It = { + key: 0, + class: "mt-1 text-xs text-muted" +}, Lt = { class: "flex items-center gap-2" }, Rt = { + key: 1, + class: "space-y-3 px-4 py-4" +}, zt = { class: "block" }, Bt = { class: "sr-only" }, Vt = ["placeholder"], Ht = { + key: 0, + class: "text-xs text-muted" +}, Ut = { + key: 1, + class: "max-h-48 space-y-1 overflow-y-auto" +}, Wt = ["onClick"], Gt = { + key: 2, + class: "text-xs text-muted" +}, Kt = ["placeholder", "aria-label"], qt = { class: "flex items-center justify-between" }, Jt = ["title", "aria-label"], Yt = { + key: 0, + class: "tabular-nums" +}, Xt = 300, Zt = /* @__PURE__ */ l({ + __name: "QuickStartOverlay", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + enabled: { type: Boolean } + }, + emits: ["open-timesheet"], + setup(l, { emit: u }) { + let d = l, p = u, h = U(), _ = v(!1), w = v(""), k = v([]), A = v(!1), j = v(null), M = v(""), N, P = n(() => ({ + notify: d.notify, + t: h + })), F = n(() => Ze(X.running?.task_id ?? null)), I = n(() => st(X.elapsedSeconds)); + T(() => d.enabled, (e) => { + e || z(); + }), T(_, (e) => { + e && X.running === null && L(); + }), T(w, () => { + clearTimeout(N), N = setTimeout(() => void L(), Xt); + }), m(() => clearTimeout(N)); + async function L() { + A.value = !0; + try { + let e = await Ue(d.client, w.value); + k.value = e, e.forEach(Qe); + } catch (e) { + k.value = [], d.notify("error", V(e, h("tasks_projects.time.tasks_failed"))); + } finally { + A.value = !1; + } + } + function R(e) { + j.value = e, Qe(e); + } + function z() { + _.value = !1, w.value = "", k.value = [], j.value = null, M.value = ""; + } + async function B() { + let e = j.value; + e !== null && await X.start(d.client, e.id, M.value.trim() || null, P.value) !== null && (d.notify("success", h("tasks_projects.timer.started", { name: e.name })), z()); + } + async function ee() { + let e = F.value, t = await X.stop(d.client, P.value); + t !== null && (d.notify("success", h("tasks_projects.timer.stopped", { + name: e, + duration: K(t.duration_minutes) + })), z()); + } + async function H() { + window.confirm(h("tasks_projects.timer.discard_confirm")) && await X.discard(d.client, P.value) && (d.notify("success", h("tasks_projects.timer.discarded")), z()); + } + return (n, u) => { + let d = b("BaseIcon"), m = b("BaseButton"); + return g(), r(t, { to: "body" }, [l.enabled ? (g(), a("div", Ot, [_.value ? (g(), a("section", { + key: 0, + class: "w-80 max-w-[calc(100vw-3rem)] rounded-xl border border-line-default bg-surface shadow-2xl", + "aria-label": S(h)("tasks_projects.timer.panel_title"), + onKeydown: O(z, ["esc"]) + }, [o("header", At, [o("h2", jt, x(S(h)("tasks_projects.timer.panel_title")), 1), o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading", + "aria-label": S(h)("tasks_projects.timer.close"), + onClick: z + }, [c(d, { + name: "XMarkIcon", + class: "h-5 w-5" + })], 8, Mt)]), S(X).running === null ? (g(), a("div", Rt, [ + o("label", zt, [o("span", Bt, x(S(h)("tasks_projects.timer.search_tasks")), 1), D(o("input", { + "onUpdate:modelValue": u[0] ||= (e) => w.value = e, + type: "search", + autocomplete: "off", + class: "w-full rounded-md border border-line-default bg-surface px-3 py-2 text-sm text-body outline-hidden focus:border-primary-400 focus:ring-1 focus:ring-primary-400", + placeholder: S(h)("tasks_projects.timer.search_tasks") + }, null, 8, Vt), [[C, w.value]])]), + A.value ? (g(), a("p", Ht, x(S(h)("tasks_projects.general.search")), 1)) : k.value.length > 0 ? (g(), a("ul", Ut, [(g(!0), a(e, null, y(k.value, (e) => (g(), a("li", { key: e.id }, [o("button", { + type: "button", + class: f(["w-full truncate rounded-md px-2 py-2 text-left text-sm hover:bg-hover", j.value?.id === e.id ? "bg-hover-strong font-medium text-heading" : "text-body"]), + onClick: (t) => R(e) + }, x(e.name), 11, Wt)]))), 128))])) : (g(), a("p", Gt, x(S(h)("tasks_projects.timer.no_tasks")), 1)), + D(o("input", { + "onUpdate:modelValue": u[1] ||= (e) => M.value = e, + type: "text", + class: "w-full rounded-md border border-line-default bg-surface px-3 py-2 text-sm text-body outline-hidden focus:border-primary-400 focus:ring-1 focus:ring-primary-400", + placeholder: S(h)("tasks_projects.timer.description_placeholder"), + "aria-label": S(h)("tasks_projects.time.fields.description") + }, null, 8, Kt), [[C, M.value]]), + o("div", qt, [o("button", { + type: "button", + class: "text-xs text-primary-500 hover:underline", + onClick: u[2] ||= (e) => p("open-timesheet") + }, x(S(h)("tasks_projects.timer.open_timesheet")), 1), c(m, { + variant: "primary", + disabled: j.value === null || S(X).busy, + onClick: B + }, { + left: E((e) => [c(d, { + name: "PlayIcon", + class: f(e.class) + }, null, 8, ["class"])]), + default: E(() => [s(" " + x(S(h)("tasks_projects.timer.start")), 1)]), + _: 1 + }, 8, ["disabled"])]) + ])) : (g(), a("div", Nt, [o("div", null, [ + o("p", Pt, x(F.value), 1), + o("p", Ft, x(I.value), 1), + S(X).running.description ? (g(), a("p", It, x(S(X).running.description), 1)) : i("", !0) + ]), o("div", Lt, [c(m, { + variant: "primary", + disabled: S(X).busy, + onClick: ee + }, { + left: E((e) => [c(d, { + name: "StopIcon", + class: f(e.class) + }, null, 8, ["class"])]), + default: E(() => [s(" " + x(S(h)("tasks_projects.timer.stop")), 1)]), + _: 1 + }, 8, ["disabled"]), c(m, { + variant: "primary-outline", + disabled: S(X).busy, + onClick: H + }, { + default: E(() => [s(x(S(h)("tasks_projects.timer.discard")), 1)]), + _: 1 + }, 8, ["disabled"])])]))], 40, kt)) : i("", !0), o("button", { + type: "button", + class: "flex items-center gap-2 rounded-full bg-btn-primary px-4 py-3 text-sm font-medium text-white shadow-lg hover:bg-btn-primary-hover", + title: S(h)("tasks_projects.timer.quick_start"), + "aria-label": S(h)("tasks_projects.timer.quick_start"), + onClick: u[3] ||= (e) => _.value = !_.value + }, [c(d, { + name: S(X).running === null ? "ClockIcon" : "StopIcon", + class: "h-5 w-5 text-white" + }, null, 8, ["name"]), S(X).running === null ? i("", !0) : (g(), a("span", Yt, x(I.value), 1))], 8, Jt)])) : i("", !0)]); + }; + } +}), Qt = { + key: 0, + class: "relative float-left m-0 ml-2" +}, $t = ["title"], en = ["aria-label"], tn = { class: "font-medium tabular-nums" }, nn = [ + "disabled", + "title", + "aria-label" +], rn = /* @__PURE__ */ l({ + __name: "TimerChip", + props: { + client: { type: [Function, Object] }, + notify: { type: Function } + }, + emits: ["open"], + setup(e, { emit: t }) { + let r = e, s = t, l = U(), u = n(() => Ze(X.running?.task_id ?? null)), d = n(() => st(X.elapsedSeconds)); + async function f() { + let e = u.value, t = await X.stop(r.client, { + notify: r.notify, + t: l + }); + t !== null && r.notify("success", l("tasks_projects.timer.stopped", { + name: e, + duration: K(t.duration_minutes) + })); + } + return (e, t) => { + let n = b("BaseIcon"); + return S(X).running === null ? i("", !0) : (g(), a("li", Qt, [o("div", { + class: "flex h-8 items-center gap-2 rounded-lg bg-white/20 px-2 text-sm text-white md:h-9 md:px-3", + title: S(l)("tasks_projects.timer.running") + }, [ + t[1] ||= o("span", { class: "inline-block h-2 w-2 shrink-0 animate-pulse rounded-full bg-white" }, null, -1), + o("button", { + type: "button", + class: "hidden max-w-32 truncate hover:underline lg:block", + "aria-label": S(l)("tasks_projects.timer.open_timesheet"), + onClick: t[0] ||= (e) => s("open") + }, x(u.value), 9, en), + o("span", tn, x(d.value), 1), + o("button", { + type: "button", + class: "rounded p-1 hover:bg-white/20 disabled:opacity-50", + disabled: S(X).busy, + title: S(l)("tasks_projects.timer.stop"), + "aria-label": S(l)("tasks_projects.timer.stop"), + onClick: f + }, [c(n, { + name: "StopIcon", + class: "h-4 w-4 text-white" + })], 8, nn) + ], 8, $t)])); + }; + } +}), an = { en: { tasks_projects: { + time: { + title: "Time", + my_time: "My time", + all_time: "All time", + this_week: "This week", + previous_week: "Previous week", + next_week: "Next week", + week_total: "Week total", + day_total: "Total", + add_entry: "Add entry", + new_entry: "New time entry", + edit_entry: "Edit time entry", + view_entry: "Time entry", + no_entries: "Nothing logged.", + empty_title: "No time logged yet", + empty_description: "Log an entry by hand, or start the timer on a task.", + unknown_member: "Removed member", + unknown_user: "Unable to identify the signed-in user. Reload the page and try again.", + billable: "Billable", + non_billable: "Not billable", + billed: "Billed", + unbilled: "Unbilled", + stamped_notice: "This entry is already on an invoice. Invoiced time is history and cannot be changed.", + created: "The time entry was saved.", + updated: "The time entry was updated.", + deleted: "The time entry was deleted.", + delete_confirm: "Delete this time entry?", + load_failed: "Unable to load the time entries.", + save_failed: "Unable to save the time entry.", + delete_failed: "Unable to delete the time entry.", + members_failed: "Unable to load the members.", + projects_failed: "Unable to load the projects.", + tasks_failed: "Unable to load the tasks.", + columns: { + date: "Date", + member: "Member", + task: "Task", + description: "Description", + duration: "Duration", + billable: "Billable", + amount: "Amount" + }, + filters: { + member: "Member", + project: "Project", + from: "From", + to: "To", + billing: "Billing", + all: "All", + any_member: "Everyone", + any_project: "Any project" + }, + fields: { + task: "Task", + task_placeholder: "Search by task name or number", + date: "Date", + mode: "Entry", + duration: "Duration", + duration_help: "Hours and minutes, as 1:30 or 1.5.", + start: "Start", + end: "End", + description: "Description", + billable: "Billable" + }, + mode: { + duration: "Duration", + range: "Start and end" + }, + task_required: "Pick a task.", + date_required: "Pick a date.", + duration_invalid: "Enter a duration like 1:30 or 1.5.", + range_invalid: "Enter a start and an end time, with the end after the start." + }, + timer: { + running: "Timer running", + quick_start: "Start a timer", + panel_title: "Quick start", + start: "Start", + stop: "Stop", + discard: "Discard", + close: "Close", + open_timesheet: "Open my time", + elapsed: "Elapsed", + search_tasks: "Search tasks", + no_tasks: "No tasks match that search.", + description_placeholder: "What are you working on? (optional)", + started: "The timer is running on {name}.", + stopped: "Logged {duration} on {name}.", + discarded: "The running timer was discarded.", + discard_confirm: "Discard the running timer? The elapsed time is not saved.", + already_running: "A timer is already running. It has been reloaded.", + start_failed: "Unable to start the timer.", + stop_failed: "Unable to stop the timer.", + discard_failed: "Unable to discard the timer." + }, + settings: { + title: "Tasks and Projects", + general_title: "General", + general_description: "The default hourly rate, the rounding increment, the first day of the week and who may see other members time.", + open_module_settings: "Open module settings", + statuses_title: "Task statuses", + statuses_description: "The columns of the board. One status is the default, where new tasks land; a closed status counts as done.", + status_name: "Name", + colour: "Colour", + colour_none: "None", + is_default: "Default", + is_closed: "Closed", + add_status: "Add status", + new_status: "New status", + move_up: "Move up", + move_down: "Move down", + no_statuses: "No statuses yet.", + status_created: "{name} was added.", + status_updated: "{name} was updated.", + status_deleted: "{name} was deleted.", + status_reordered: "The order was saved.", + status_delete_confirm: "Delete {name}?", + status_name_required: "Enter a status name.", + load_failed: "Unable to load the task statuses.", + save_failed: "Unable to save the task status.", + delete_failed: "Unable to delete the task status.", + reorder_failed: "Unable to save the new order.", + forbidden: "Your role does not allow managing the board columns." + } +} } }, on = { class: "relative table-container" }, sn = { class: "block max-w-64 truncate" }, cn = { class: "tabular-nums" }, ln = { + key: 1, + class: "text-subtle" +}, un = /* @__PURE__ */ l({ + __name: "AllTimeTable", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + members: {}, + projects: {}, + reloadToken: {} + }, + emits: ["edit", "delete"], + setup(e, { emit: t }) { + let l = e, u = t, d = U(), p = v(null), m = _({ + memberId: null, + projectId: null, + from: "", + to: "", + billing: "ALL" + }), h = n(() => [{ + id: 0, + label: d("tasks_projects.time.filters.any_member") + }, ...l.members.map((e) => ({ + id: e.id, + label: e.name + }))]), y = n(() => [{ + id: 0, + label: d("tasks_projects.time.filters.any_project") + }, ...l.projects.map((e) => ({ + id: e.id, + label: e.name + }))]), C = n(() => [ + { + id: "ALL", + label: d("tasks_projects.time.filters.all") + }, + { + id: "BILLED", + label: d("tasks_projects.time.billed") + }, + { + id: "UNBILLED", + label: d("tasks_projects.time.unbilled") + } + ]), w = n({ + get: () => A(h.value, m.memberId ?? 0), + set: (e) => { + m.memberId = typeof e.id == "number" && e.id > 0 ? e.id : null; + } + }), D = n({ + get: () => A(y.value, m.projectId ?? 0), + set: (e) => { + m.projectId = typeof e.id == "number" && e.id > 0 ? e.id : null; + } + }), O = n({ + get: () => A(C.value, m.billing), + set: (e) => { + m.billing = typeof e.id == "string" ? e.id : "ALL"; + } + }), k = n(() => [ + { + key: "date", + label: d("tasks_projects.time.columns.date"), + sortable: !1 + }, + { + key: "member", + label: d("tasks_projects.time.columns.member"), + sortable: !1 + }, + { + key: "task", + label: d("tasks_projects.time.columns.task"), + sortable: !1, + tdClass: "font-medium text-heading" + }, + { + key: "description", + label: d("tasks_projects.time.columns.description"), + sortable: !1 + }, + { + key: "duration", + label: d("tasks_projects.time.columns.duration"), + sortable: !1 + }, + { + key: "billable", + label: d("tasks_projects.time.columns.billable"), + sortable: !1 + }, + { + key: "amount", + label: d("tasks_projects.time.columns.amount"), + sortable: !1 + }, + { + key: "actions", + label: d("tasks_projects.general.actions"), + sortable: !1, + tdClass: "text-right text-sm font-medium" + } + ]); + T(m, () => j()), T(() => l.reloadToken, () => j(!0)); + function A(e, t) { + return e.find((e) => e.id === t) ?? e[0]; + } + function j(e = !1) { + p.value?.refresh(e); + } + function M() { + m.memberId = null, m.projectId = null, m.from = "", m.to = "", m.billing = "ALL"; + } + function N(e) { + m.from = e ? oe(e) : ""; + } + function P(e) { + m.to = e ? oe(e) : ""; + } + function F(e) { + let t = l.members.find((t) => t.id === e); + return t === void 0 ? l.members.length === 0 ? `#${e}` : d("tasks_projects.time.unknown_member") : t.name; + } + async function I({ page: e }) { + let t = { + page: e, + limit: 25 + }; + m.memberId !== null && (t.user_id = m.memberId), m.projectId !== null && (t.project_id = m.projectId), m.from !== "" && (t.from = m.from), m.to !== "" && (t.to = m.to), m.billing !== "ALL" && (t.billed = m.billing === "BILLED"); + try { + let e = await ke(l.client, t), n = e.data ?? []; + return $e(l.client, n.map((e) => e.task_id).filter((e) => typeof e == "number")), { + data: n, + pagination: L(e.meta, n.length) + }; + } catch (e) { + return l.notify("error", V(e, d("tasks_projects.time.load_failed"))), { + data: [], + pagination: L(null, 0) + }; + } + } + function L(e, t) { + return { + totalPages: e?.last_page ?? 1, + currentPage: e?.current_page ?? 1, + totalCount: e?.total ?? t, + count: t, + limit: e?.per_page ?? 25 + }; + } + return (e, t) => { + let n = b("BaseSelectInput"), l = b("BaseInputGroup"), _ = b("BaseDatePicker"), v = b("BaseFilterWrapper"), T = b("BaseBadge"), A = b("BaseFormatMoney"), j = b("BaseIcon"), L = b("BaseDropdownItem"), R = b("BaseDropdown"), z = b("BaseTable"); + return g(), a("section", null, [c(v, { + show: "", + "row-on-xl": "", + class: "mt-3", + onClear: M + }, { + default: E(() => [ + c(l, { + label: S(d)("tasks_projects.time.filters.member"), + class: "mt-2 flex-1" + }, { + default: E(() => [c(n, { + modelValue: w.value, + "onUpdate:modelValue": t[0] ||= (e) => w.value = e, + options: h.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + _: 1 + }, 8, ["label"]), + c(l, { + label: S(d)("tasks_projects.time.filters.project"), + class: "mt-2 flex-1" + }, { + default: E(() => [c(n, { + modelValue: D.value, + "onUpdate:modelValue": t[1] ||= (e) => D.value = e, + options: y.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + _: 1 + }, 8, ["label"]), + c(l, { + label: S(d)("tasks_projects.time.filters.from"), + class: "mt-2 flex-1" + }, { + default: E(() => [c(_, { + "model-value": m.from, + "onUpdate:modelValue": N + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"]), + c(l, { + label: S(d)("tasks_projects.time.filters.to"), + class: "mt-2 flex-1" + }, { + default: E(() => [c(_, { + "model-value": m.to, + "onUpdate:modelValue": P + }, null, 8, ["model-value"])]), + _: 1 + }, 8, ["label"]), + c(l, { + label: S(d)("tasks_projects.time.filters.billing"), + class: "mt-2 flex-1" + }, { + default: E(() => [c(n, { + modelValue: O.value, + "onUpdate:modelValue": t[2] ||= (e) => O.value = e, + options: C.value, + "label-key": "label" + }, null, 8, ["modelValue", "options"])]), + _: 1 + }, 8, ["label"]) + ]), + _: 1 + }), o("div", on, [c(z, { + ref_key: "tableRef", + ref: p, + data: I, + columns: k.value, + class: "mt-3" + }, { + "cell-date": E(({ row: e }) => [s(x(S(ae)(S(lt)(e.data.started_at))), 1)]), + "cell-member": E(({ row: e }) => [s(x(F(e.data.user_id)), 1)]), + "cell-task": E(({ row: e }) => [s(x(S(Ze)(e.data.task_id)), 1)]), + "cell-description": E(({ row: e }) => [o("span", sn, x(e.data.description || "-"), 1)]), + "cell-duration": E(({ row: e }) => [o("span", cn, x(S(K)(e.data.duration_minutes)), 1)]), + "cell-billable": E(({ row: e }) => [c(T, { class: f(["rounded-full", e.data.billable ? "bg-primary-50! text-primary-500!" : "bg-surface-tertiary! text-muted!"]) }, { + default: E(() => [s(x(e.data.billable ? S(d)("tasks_projects.time.billable") : S(d)("tasks_projects.time.non_billable")), 1)]), + _: 2 + }, 1032, ["class"])]), + "cell-amount": E(({ row: e }) => [e.data.billable ? (g(), r(A, { + key: 0, + amount: e.data.amount + }, null, 8, ["amount"])) : (g(), a("span", ln, "-"))]), + "cell-actions": E(({ row: e }) => [c(R, null, { + activator: E(() => [c(j, { + name: "EllipsisHorizontalIcon", + class: "h-5 text-muted" + })]), + default: E(() => [c(L, { onClick: (t) => u("edit", e.data) }, { + default: E(() => [c(j, { + name: "PencilIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + x(S(d)("tasks_projects.general.edit")), 1)]), + _: 1 + }, 8, ["onClick"]), e.data.invoice_id === null ? (g(), r(L, { + key: 0, + onClick: (t) => u("delete", e.data) + }, { + default: E(() => [c(j, { + name: "TrashIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), s(" " + x(S(d)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["onClick"])) : i("", !0)]), + _: 2 + }, 1024)]), + _: 1 + }, 8, ["columns"])])]); + }; + } +}), dn = { class: "flex w-full items-center justify-between" }, fn = { class: "space-y-5 px-6 py-6" }, pn = { + key: 0, + class: "rounded-md bg-alert-warning-bg px-3 py-2 text-sm text-alert-warning-text" +}, mn = { class: "inline-flex overflow-hidden rounded-md border border-line-default" }, hn = ["disabled", "onClick"], gn = { + key: 1, + class: "text-sm text-muted" +}, _n = { class: "flex items-center justify-between border-t border-line-default px-6 py-4" }, vn = { key: 1 }, yn = { class: "flex space-x-3" }, bn = "09:00", xn = /* @__PURE__ */ l({ + __name: "TimeEntryModal", + props: { + show: { type: Boolean }, + client: { type: [Function, Object] }, + notify: { type: Function }, + entry: {}, + defaultDate: {} + }, + emits: [ + "close", + "saved", + "deleted" + ], + setup(t, { emit: l }) { + let u = t, d = l, p = ["duration", "range"], m = U(), h = _({ + date: "", + mode: "duration", + duration: "", + start: bn, + end: "", + description: "", + billable: !0 + }), C = v(null), w = v({}), D = v(!1), O = v(!1), A = n(() => u.entry !== null), j = n(() => u.entry?.invoice_id != null), M = n(() => j.value ? m("tasks_projects.time.view_entry") : A.value ? m("tasks_projects.time.edit_entry") : m("tasks_projects.time.new_entry")); + T(() => u.show, (e) => { + e && N(); + }, { immediate: !0 }); + function N() { + let e = u.entry; + w.value = {}, C.value = null, h.date = e ? lt(e.started_at) : u.defaultDate ?? q(/* @__PURE__ */ new Date()), h.duration = e ? K(e.duration_minutes) : "", h.start = e?.started_at ? ut(e.started_at) : bn, h.end = e?.ended_at ? ut(e.ended_at) : "", h.description = e?.description ?? "", h.billable = !e || e.billable, h.mode = e !== null && P(e) ? "range" : "duration", h.date === "" && (h.date = u.defaultDate ?? q(/* @__PURE__ */ new Date())), e !== null && F(e.task_id); + } + function P(e) { + if (!e.started_at || !e.ended_at) return !1; + let t = new Date(e.started_at).getTime(), n = new Date(e.ended_at).getTime(); + return Number.isNaN(t) || Number.isNaN(n) ? !1 : Math.round((n - t) / 6e4) === e.duration_minutes; + } + async function F(e) { + try { + let t = await We(u.client, e); + C.value = t, Qe(t); + } catch {} + } + async function I(e) { + try { + let t = await Ue(u.client, e ?? ""); + return t.forEach(Qe), t; + } catch (e) { + return u.notify("error", V(e, m("tasks_projects.time.tasks_failed"))), []; + } + } + function L(e) { + h.date = e ? oe(e) : ""; + } + function R(e) { + C.value = e, e !== null && u.entry === null && (h.billable = e.billable !== !1); + } + function z() { + let e = {}, t = C.value; + (t === null || typeof t.id != "number") && (e.task_id = m("tasks_projects.time.task_required")), h.date === "" && (e.date = m("tasks_projects.time.date_required")); + let n = dt(h.date, h.mode === "range" ? h.start : bn); + n === null && (e.started_at = m("tasks_projects.time.range_invalid")); + let r = h.mode === "duration" ? ct(h.duration) : null; + h.mode === "duration" && r === null && (e.duration_minutes = m("tasks_projects.time.duration_invalid")); + let i = h.mode === "range" ? dt(h.date, h.end) : null; + if (h.mode === "range" && (i === null || n === null || i <= n) && (e.ended_at = m("tasks_projects.time.range_invalid")), w.value = e, Object.keys(e).length > 0 || t === null || n === null) return null; + let a = { + task_id: t.id, + started_at: n, + description: h.description.trim() || null, + billable: h.billable + }; + return h.mode === "duration" && r !== null ? (a.duration_minutes = r, a.ended_at = ft(n, r)) : a.ended_at = i, a; + } + async function B() { + if (D.value || j.value) return; + let e = z(); + if (e !== null) { + D.value = !0; + try { + let t = u.entry, n = t ? await Me(u.client, t.id, e) : await je(u.client, e); + d("saved", n); + } catch (e) { + w.value = H(e), u.notify("error", V(e, m("tasks_projects.time.save_failed"))); + } finally { + D.value = !1; + } + } + } + async function ee() { + let e = u.entry; + if (!(e === null || O.value || j.value) && window.confirm(m("tasks_projects.time.delete_confirm"))) { + O.value = !0; + try { + await Ne(u.client, e.id), d("deleted", e); + } catch (e) { + u.notify("error", V(e, m("tasks_projects.time.delete_failed"))); + } finally { + O.value = !1; + } + } + } + return (n, l) => { + let u = b("BaseIcon"), _ = b("BaseMultiselect"), v = b("BaseInputGroup"), T = b("BaseDatePicker"), N = b("BaseInputGrid"), P = b("BaseInput"), F = b("BaseTextarea"), z = b("BaseSwitch"), V = b("BaseButton"), H = b("BaseModal"); + return g(), r(H, { + show: t.show, + onClose: l[8] ||= (e) => d("close") + }, { + header: E(() => [o("div", dn, [o("span", null, x(M.value), 1), c(u, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: l[0] ||= (e) => d("close") + })])]), + default: E(() => [o("form", { onSubmit: k(B, ["prevent"]) }, [o("div", fn, [ + j.value ? (g(), a("p", pn, x(S(m)("tasks_projects.time.stamped_notice")), 1)) : i("", !0), + c(v, { + label: S(m)("tasks_projects.time.fields.task"), + error: w.value.task_id, + required: "" + }, { + default: E(() => [c(_, { + "model-value": C.value, + options: I, + disabled: j.value, + invalid: !!w.value.task_id, + placeholder: S(m)("tasks_projects.time.fields.task_placeholder"), + "initial-search": C.value?.name ?? "", + delay: 400, + "filter-results": !1, + "value-prop": "id", + "track-by": "name", + label: "name", + object: "", + searchable: "", + "preserve-search": "", + "resolve-on-load": "", + "onUpdate:modelValue": l[1] ||= (e) => R(e) + }, null, 8, [ + "model-value", + "disabled", + "invalid", + "placeholder", + "initial-search" + ])]), + _: 1 + }, 8, ["label", "error"]), + c(N, null, { + default: E(() => [c(v, { + label: S(m)("tasks_projects.time.fields.date"), + error: w.value.date, + required: "" + }, { + default: E(() => [c(T, { + "model-value": h.date, + disabled: j.value, + invalid: !!w.value.date, + "onUpdate:modelValue": L + }, null, 8, [ + "model-value", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"]), c(v, { label: S(m)("tasks_projects.time.fields.mode") }, { + default: E(() => [o("div", mn, [(g(), a(e, null, y(p, (e) => o("button", { + key: e, + type: "button", + class: f(["px-3 py-2 text-sm", h.mode === e ? "bg-primary-500 text-white" : "bg-surface text-body hover:bg-hover"]), + disabled: j.value, + onClick: (t) => h.mode = e + }, x(S(m)(`tasks_projects.time.mode.${e}`)), 11, hn)), 64))])]), + _: 1 + }, 8, ["label"])]), + _: 1 + }), + h.mode === "duration" ? (g(), r(v, { + key: 1, + label: S(m)("tasks_projects.time.fields.duration"), + error: w.value.duration_minutes, + "help-text": S(m)("tasks_projects.time.fields.duration_help"), + required: "" + }, { + default: E(() => [c(P, { + modelValue: h.duration, + "onUpdate:modelValue": l[2] ||= (e) => h.duration = e, + type: "text", + inputmode: "text", + placeholder: "1:30", + disabled: j.value, + invalid: !!w.value.duration_minutes + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ])) : (g(), r(N, { key: 2 }, { + default: E(() => [c(v, { + label: S(m)("tasks_projects.time.fields.start"), + error: w.value.started_at, + required: "" + }, { + default: E(() => [c(P, { + modelValue: h.start, + "onUpdate:modelValue": l[3] ||= (e) => h.start = e, + type: "time", + disabled: j.value, + invalid: !!w.value.started_at + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"]), c(v, { + label: S(m)("tasks_projects.time.fields.end"), + error: w.value.ended_at, + required: "" + }, { + default: E(() => [c(P, { + modelValue: h.end, + "onUpdate:modelValue": l[4] ||= (e) => h.end = e, + type: "time", + disabled: j.value, + invalid: !!w.value.ended_at + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"])]), + _: 1 + })), + c(v, { + label: S(m)("tasks_projects.time.fields.description"), + error: w.value.description + }, { + default: E(() => [c(F, { + modelValue: h.description, + "onUpdate:modelValue": l[5] ||= (e) => h.description = e, + row: 3, + disabled: j.value, + invalid: !!w.value.description + }, null, 8, [ + "modelValue", + "disabled", + "invalid" + ])]), + _: 1 + }, 8, ["label", "error"]), + c(v, { + label: S(m)("tasks_projects.time.fields.billable"), + error: w.value.billable + }, { + default: E(() => [j.value ? (g(), a("span", gn, x(h.billable ? S(m)("tasks_projects.time.billable") : S(m)("tasks_projects.time.non_billable")), 1)) : (g(), r(z, { + key: 0, + modelValue: h.billable, + "onUpdate:modelValue": l[6] ||= (e) => h.billable = e, + class: "flex" + }, null, 8, ["modelValue"]))]), + _: 1 + }, 8, ["label", "error"]) + ]), o("div", _n, [A.value && !j.value ? (g(), r(V, { + key: 0, + type: "button", + variant: "danger", + size: "sm", + loading: O.value, + disabled: O.value, + onClick: ee + }, { + default: E(() => [s(x(S(m)("tasks_projects.general.delete")), 1)]), + _: 1 + }, 8, ["loading", "disabled"])) : (g(), a("span", vn)), o("div", yn, [c(V, { + type: "button", + variant: "primary-outline", + onClick: l[7] ||= (e) => d("close") + }, { + default: E(() => [s(x(j.value ? S(m)("tasks_projects.timer.close") : S(m)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), j.value ? i("", !0) : (g(), r(V, { + key: 0, + type: "submit", + variant: "primary", + loading: D.value, + disabled: D.value + }, { + default: E(() => [s(x(A.value ? S(m)("tasks_projects.general.update") : S(m)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["loading", "disabled"]))])])], 32)]), + _: 1 + }, 8, ["show"]); + }; + } +}), Sn = { class: "mt-4 flex flex-wrap items-center justify-between gap-3" }, Cn = { class: "flex items-center gap-2" }, wn = { class: "ml-1 text-sm text-muted" }, Tn = { class: "flex items-center gap-2 text-sm" }, En = { class: "text-muted" }, Dn = { class: "text-lg font-semibold tabular-nums text-heading" }, On = { + key: 0, + class: "mt-6 text-sm text-muted" +}, kn = { + key: 1, + class: "mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4 xl:grid-cols-7" +}, An = { class: "flex items-baseline justify-between" }, jn = { class: "text-xs font-semibold tracking-wide text-heading uppercase" }, Mn = { class: "text-xs text-muted" }, Nn = { class: "text-sm font-medium tabular-nums text-heading" }, Pn = { class: "mt-3 flex-1 space-y-2" }, Fn = ["onClick"], In = { class: "flex items-center justify-between gap-2" }, Ln = { class: "truncate text-xs font-medium text-heading" }, Rn = { class: "shrink-0 text-xs tabular-nums text-muted" }, zn = { + key: 0, + class: "mt-1 block truncate text-xs text-muted" +}, Bn = { class: "mt-1 flex items-center gap-1" }, Vn = { class: "text-[11px] text-subtle" }, Hn = { + key: 0, + class: "text-[11px] text-subtle" +}, Un = { + key: 0, + class: "py-2 text-xs text-subtle" +}, Wn = ["onClick"], Gn = { + key: 2, + class: "mt-4 text-center text-sm text-subtle" +}, Kn = /* @__PURE__ */ l({ + __name: "WeekTimesheet", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + userId: {}, + weekStart: {}, + reloadToken: {} + }, + emits: ["add", "edit"], + setup(t, { emit: l }) { + let u = t, d = l, p = U(), m = v(pt(/* @__PURE__ */ new Date(), u.weekStart)), h = v([]), _ = v(!1), C = n(() => mt(m.value)), w = n(() => { + let e = C.value[0], t = C.value[C.value.length - 1]; + return `${gt(e).day} - ${gt(t).day}`; + }), D = n(() => C.value.map((e) => { + let t = q(e), n = h.value.filter((e) => lt(e.started_at) === t), r = gt(e); + return { + key: t, + weekday: r.weekday, + day: r.day, + today: _t(e), + entries: n, + minutes: A(n) + }; + })), O = n(() => A(h.value)), k = n(() => !_.value && h.value.length === 0); + T(() => u.weekStart, (e) => { + m.value = pt(m.value, e); + }), T([ + m, + () => u.userId, + () => u.reloadToken + ], () => void j(), { immediate: !0 }); + function A(e) { + return e.reduce((e, t) => e + (t.duration_minutes ?? 0), 0); + } + async function j() { + if (u.userId === null) { + h.value = []; + return; + } + _.value = !0; + try { + let e = await Ae(u.client, { + user_id: u.userId, + from: q(C.value[0]), + to: q(C.value[C.value.length - 1]) + }); + h.value = e, $e(u.client, e.map((e) => e.task_id).filter((e) => typeof e == "number")); + } catch (e) { + h.value = [], u.notify("error", V(e, p("tasks_projects.time.load_failed"))); + } finally { + _.value = !1; + } + } + function M(e) { + m.value = ht(m.value, e * 7); + } + function N() { + m.value = pt(/* @__PURE__ */ new Date(), u.weekStart); + } + return (n, l) => { + let u = b("BaseIcon"), m = b("BaseButton"), h = b("BaseSpinner"); + return g(), a("section", null, [ + o("header", Sn, [o("div", Cn, [ + c(m, { + variant: "white", + size: "sm", + title: S(p)("tasks_projects.time.previous_week"), + onClick: l[0] ||= (e) => M(-1) + }, { + default: E(() => [c(u, { + name: "ChevronLeftIcon", + class: "h-4 w-4" + })]), + _: 1 + }, 8, ["title"]), + c(m, { + variant: "white", + size: "sm", + onClick: N + }, { + default: E(() => [s(x(S(p)("tasks_projects.time.this_week")), 1)]), + _: 1 + }), + c(m, { + variant: "white", + size: "sm", + title: S(p)("tasks_projects.time.next_week"), + onClick: l[1] ||= (e) => M(1) + }, { + default: E(() => [c(u, { + name: "ChevronRightIcon", + class: "h-4 w-4" + })]), + _: 1 + }, 8, ["title"]), + o("span", wn, x(w.value), 1) + ]), o("div", Tn, [ + o("span", En, x(S(p)("tasks_projects.time.week_total")), 1), + o("span", Dn, x(S(K)(O.value)), 1), + _.value ? (g(), r(h, { + key: 0, + class: "h-4 w-4 text-primary-500" + })) : i("", !0) + ])]), + t.userId === null ? (g(), a("p", On, x(S(p)("tasks_projects.time.unknown_user")), 1)) : (g(), a("div", kn, [(g(!0), a(e, null, y(D.value, (t) => (g(), a("article", { + key: t.key, + class: f(["flex min-h-40 flex-col rounded-xl border bg-surface p-3", t.today ? "border-primary-400" : "border-line-default"]) + }, [ + o("header", An, [o("div", null, [o("p", jn, x(t.weekday), 1), o("p", Mn, x(t.day), 1)]), o("span", Nn, x(S(K)(t.minutes)), 1)]), + o("ul", Pn, [(g(!0), a(e, null, y(t.entries, (e) => (g(), a("li", { key: e.id }, [o("button", { + type: "button", + class: "w-full rounded-md border border-line-light px-2 py-2 text-left hover:bg-hover", + onClick: (t) => d("edit", e) + }, [ + o("span", In, [o("span", Ln, x(S(Ze)(e.task_id)), 1), o("span", Rn, x(S(K)(e.duration_minutes)), 1)]), + e.description ? (g(), a("span", zn, x(e.description), 1)) : i("", !0), + o("span", Bn, [ + o("span", { class: f(["inline-block h-1.5 w-1.5 rounded-full", e.billable ? "bg-status-green" : "bg-line-strong"]) }, null, 2), + o("span", Vn, x(e.billable ? S(p)("tasks_projects.time.billable") : S(p)("tasks_projects.time.non_billable")), 1), + e.invoice_id === null ? i("", !0) : (g(), a("span", Hn, " - " + x(S(p)("tasks_projects.time.billed")), 1)) + ]) + ], 8, Fn)]))), 128)), t.entries.length === 0 ? (g(), a("li", Un, x(S(p)("tasks_projects.time.no_entries")), 1)) : i("", !0)]), + o("button", { + type: "button", + class: "mt-2 flex items-center justify-center gap-1 rounded-md border border-dashed border-line-default py-1.5 text-xs text-muted hover:bg-hover hover:text-heading", + onClick: (e) => d("add", t.key) + }, [c(u, { + name: "PlusIcon", + class: "h-4 w-4" + }), s(" " + x(S(p)("tasks_projects.time.add_entry")), 1)], 8, Wn) + ], 2))), 128))])), + k.value && t.userId !== null ? (g(), a("p", Gn, x(S(p)("tasks_projects.time.empty_description")), 1)) : i("", !0) + ]); + }; + } +}), Z = { + default_rate: 0, + rounding_minutes: 1, + week_start: 1, + members_see_all_time: !1, + rounding_increments: [ + 1, + 6, + 15, + 30 + ] +}, Q = _({ + adminMode: !1, + userId: null, + settings: { ...Z }, + companySession: 0, + loading: !1 +}); +async function qn(e) { + if (Q.adminMode) return; + Q.loading = !0; + let [t, n] = await Promise.all([qe(e).catch(() => null), Ke(e).catch(() => null)]); + Q.userId = t, Q.settings = Xn(n), Q.loading = !1; +} +function Jn() { + Q.userId = null, Q.settings = { ...Z }, Q.companySession += 1, Q.loading = !1; +} +function Yn(e) { + Q.adminMode = e; +} +function Xn(e) { + if (typeof e != "object" || !e) return { ...Z }; + let t = Array.isArray(e.rounding_increments) ? e.rounding_increments.filter((e) => typeof e == "number") : Z.rounding_increments; + return { + default_rate: Zn(e.default_rate, Z.default_rate), + rounding_minutes: Zn(e.rounding_minutes, Z.rounding_minutes), + week_start: Qn(e.week_start), + members_see_all_time: e.members_see_all_time === !0, + rounding_increments: t.length > 0 ? t : Z.rounding_increments + }; +} +function Zn(e, t) { + return typeof e == "number" && Number.isFinite(e) ? e : t; +} +function Qn(e) { + return typeof e == "number" && Number.isInteger(e) && e >= 0 && e <= 6 ? e : Z.week_start; +} +//#endregion +//#region resources/js/pages/TimePage.vue?vue&type=script&setup=true&lang.ts +var $n = { class: "flex items-center justify-end space-x-5" }, er = { + key: 0, + class: "hidden items-center gap-2 text-sm text-muted sm:flex" +}, tr = { + key: 0, + class: "mt-4 flex gap-6 border-b border-line-default" +}, nr = 5, rr = /* @__PURE__ */ l({ + __name: "TimePage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(e) { + let t = e, l = U(), u = v("MINE"), d = v(!1), p = v([]), m = v([]), _ = v(!1), y = v(null), C = v(q(/* @__PURE__ */ new Date())), w = v(0), T = n(() => Q.settings.week_start), D = n(() => Q.userId); + h(() => void O()); + async function O() { + Q.userId === null && await qn(t.client), d.value = Q.settings.members_see_all_time || await k(), d.value && await Promise.all([A(), j()]); + } + async function k() { + try { + return ((await ke(t.client, { limit: nr })).data ?? []).some((e) => e.user_id !== Q.userId); + } catch { + return !1; + } + } + async function A() { + try { + p.value = await Ge(t.client); + } catch { + p.value = []; + } + } + async function j() { + try { + let e = await P(t.client, { limit: 100 }); + m.value = e.data ?? []; + } catch { + m.value = []; + } + } + function M(e) { + y.value = null, C.value = e ?? q(/* @__PURE__ */ new Date()), _.value = !0; + } + function N(e) { + y.value = e, _.value = !0; + } + function F() { + let e = y.value ? l("tasks_projects.time.updated") : l("tasks_projects.time.created"); + _.value = !1, y.value = null, t.notify("success", e), w.value += 1; + } + function I() { + _.value = !1, y.value = null, t.notify("success", l("tasks_projects.time.deleted")), w.value += 1; + } + async function L(e) { + if (window.confirm(l("tasks_projects.time.delete_confirm"))) try { + await Ne(t.client, e.id), t.notify("success", l("tasks_projects.time.deleted")), w.value += 1; + } catch (e) { + t.notify("error", V(e, l("tasks_projects.time.delete_failed"))); + } + } + function R(e) { + return u.value === e ? "border-primary-500 text-primary-500" : "border-transparent text-muted hover:border-line-strong hover:text-heading"; + } + return (t, n) => { + let h = b("BaseBreadcrumbItem"), v = b("BaseBreadcrumb"), O = b("BaseIcon"), k = b("BaseButton"), A = b("BasePageHeader"), j = b("BasePage"); + return g(), r(j, null, { + default: E(() => [ + c(A, { title: S(l)("tasks_projects.time.title") }, { + actions: E(() => [o("div", $n, [S(X).running === null ? i("", !0) : (g(), a("span", er, [c(O, { + name: "ClockIcon", + class: "h-4 w-4 text-primary-500" + }), s(" " + x(S(l)("tasks_projects.timer.running")), 1)])), c(k, { + variant: "primary", + onClick: n[0] ||= (e) => M() + }, { + left: E((e) => [c(O, { + name: "PlusIcon", + class: f(e.class) + }, null, 8, ["class"])]), + default: E(() => [s(" " + x(S(l)("tasks_projects.time.add_entry")), 1)]), + _: 1 + })])]), + default: E(() => [c(v, null, { + default: E(() => [ + c(h, { + title: S(l)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), + c(h, { + title: S(l)("tasks_projects.projects.title"), + to: "/admin/modules/tasks-projects" + }, null, 8, ["title"]), + c(h, { + title: S(l)("tasks_projects.time.title"), + to: "#", + active: "" + }, null, 8, ["title"]) + ]), + _: 1 + })]), + _: 1 + }, 8, ["title"]), + d.value ? (g(), a("nav", tr, [o("button", { + type: "button", + class: f(["-mb-px border-b-2 px-1 pb-3 text-sm font-medium", R("MINE")]), + onClick: n[1] ||= (e) => u.value = "MINE" + }, x(S(l)("tasks_projects.time.my_time")), 3), o("button", { + type: "button", + class: f(["-mb-px border-b-2 px-1 pb-3 text-sm font-medium", R("ALL")]), + onClick: n[2] ||= (e) => u.value = "ALL" + }, x(S(l)("tasks_projects.time.all_time")), 3)])) : i("", !0), + u.value === "MINE" ? (g(), r(Kn, { + key: 1, + client: e.client, + notify: e.notify, + "user-id": D.value, + "week-start": T.value, + "reload-token": w.value, + onAdd: M, + onEdit: N + }, null, 8, [ + "client", + "notify", + "user-id", + "week-start", + "reload-token" + ])) : (g(), r(un, { + key: 2, + client: e.client, + notify: e.notify, + members: p.value, + projects: m.value, + "reload-token": w.value, + onEdit: N, + onDelete: L + }, null, 8, [ + "client", + "notify", + "members", + "projects", + "reload-token" + ])), + c(xn, { + show: _.value, + client: e.client, + notify: e.notify, + entry: y.value, + "default-date": C.value, + onClose: n[3] ||= (e) => _.value = !1, + onSaved: F, + onDeleted: I + }, null, 8, [ + "show", + "client", + "notify", + "entry", + "default-date" + ]) + ]), + _: 1 + }); + }; + } +}), ir = { + key: 0, + class: "text-sm text-muted" +}, ar = { key: 1 }, or = { + key: 0, + class: "flex items-center gap-2 text-sm text-muted" +}, sr = { + key: 1, + class: "text-sm text-muted" +}, cr = { + key: 2, + class: "divide-y divide-line-light" +}, lr = { + key: 0, + class: "space-y-3" +}, ur = { class: "flex flex-wrap items-center gap-2" }, dr = ["aria-label", "onClick"], fr = { class: "flex flex-wrap items-center gap-6" }, pr = { class: "flex items-center gap-2 text-sm text-body" }, mr = { class: "flex items-center gap-2 text-sm text-body" }, hr = { class: "flex gap-3" }, gr = { + key: 1, + class: "flex items-center gap-3" +}, _r = { class: "min-w-0 flex-1 truncate text-sm font-medium text-heading" }, vr = { class: "flex items-center gap-1" }, yr = [ + "disabled", + "title", + "aria-label", + "onClick" +], br = [ + "disabled", + "title", + "aria-label", + "onClick" +], xr = [ + "title", + "aria-label", + "onClick" +], Sr = [ + "disabled", + "title", + "aria-label", + "onClick" +], Cr = { + key: 3, + class: "mt-4 space-y-3 rounded-lg border border-line-default p-3" +}, wr = { class: "flex flex-wrap items-center gap-2" }, Tr = ["aria-label", "onClick"], Er = { class: "flex flex-wrap items-center gap-6" }, Dr = { class: "flex items-center gap-2 text-sm text-body" }, Or = { class: "flex items-center gap-2 text-sm text-body" }, kr = { class: "flex gap-3" }, Ar = /* @__PURE__ */ l({ + __name: "TaskStatusEditor", + props: { + client: { type: [Function, Object] }, + notify: { type: Function } + }, + setup(t) { + let l = t, u = [ + "#94a3b8", + "#3b82f6", + "#22c55e", + "#f59e0b", + "#ef4444", + "#a855f7", + "#0891b2", + "#64748b" + ], d = U(), m = v([]), C = v(!0), w = v(!1), T = v(!1), D = v(null), O = v(!1), k = _({ + name: "", + colour: "", + is_default: !1, + is_closed: !1 + }), A = n(() => !C.value && m.value.length === 0); + h(() => void j()); + async function j() { + C.value = !0; + try { + m.value = await Re(l.client), w.value = !1; + } catch (e) { + m.value = [], w.value = rt(e), w.value || l.notify("error", V(e, d("tasks_projects.settings.load_failed"))); + } finally { + C.value = !1; + } + } + function M(e) { + O.value = !1, D.value = e.id, k.name = e.name, k.colour = e.colour ?? "", k.is_default = e.is_default, k.is_closed = e.is_closed; + } + function N() { + D.value = null, O.value = !0, k.name = "", k.colour = u[0], k.is_default = !1, k.is_closed = !1; + } + function P() { + D.value = null, O.value = !1; + } + function F() { + return { + name: k.name.trim(), + colour: k.colour || null, + is_default: k.is_default, + is_closed: k.is_closed + }; + } + async function I() { + if (T.value) return; + if (k.name.trim() === "") { + l.notify("error", d("tasks_projects.settings.status_name_required")); + return; + } + let e = D.value, t = k.name.trim(); + T.value = !0; + try { + e === null ? (await ze(l.client, F()), l.notify("success", d("tasks_projects.settings.status_created", { name: t }))) : (await Be(l.client, e, F()), l.notify("success", d("tasks_projects.settings.status_updated", { name: t }))), P(), await j(); + } catch (e) { + l.notify("error", V(e, d("tasks_projects.settings.save_failed"))); + } finally { + T.value = !1; + } + } + async function L(e) { + if (!T.value && window.confirm(d("tasks_projects.settings.status_delete_confirm", { name: e.name }))) { + T.value = !0; + try { + await Ve(l.client, e.id), l.notify("success", d("tasks_projects.settings.status_deleted", { name: e.name })), P(), await j(); + } catch (e) { + l.notify("error", V(e, d("tasks_projects.settings.delete_failed"))); + } finally { + T.value = !1; + } + } + } + async function R(e, t) { + let n = e + t; + if (T.value || n < 0 || n >= m.value.length) return; + let r = [...m.value]; + r.splice(n, 0, ...r.splice(e, 1)), m.value = r, T.value = !0; + try { + m.value = await He(l.client, r.map((e) => e.id)), l.notify("success", d("tasks_projects.settings.status_reordered")); + } catch (e) { + l.notify("error", V(e, d("tasks_projects.settings.reorder_failed"))), await j(); + } finally { + T.value = !1; + } + } + return (t, n) => { + let l = b("BaseSpinner"), h = b("BaseInput"), _ = b("BaseInputGroup"), v = b("BaseSwitch"), j = b("BaseButton"), F = b("BaseBadge"), z = b("BaseIcon"); + return g(), a("div", null, [w.value ? (g(), a("p", ir, x(S(d)("tasks_projects.settings.forbidden")), 1)) : (g(), a("div", ar, [C.value ? (g(), a("div", or, [c(l, { class: "h-4 w-4 text-primary-500" })])) : A.value ? (g(), a("p", sr, x(S(d)("tasks_projects.settings.no_statuses")), 1)) : (g(), a("ul", cr, [(g(!0), a(e, null, y(m.value, (t, l) => (g(), a("li", { + key: t.id, + class: "py-3" + }, [D.value === t.id ? (g(), a("div", lr, [ + c(_, { + label: S(d)("tasks_projects.settings.status_name"), + required: "" + }, { + default: E(() => [c(h, { + modelValue: k.name, + "onUpdate:modelValue": n[0] ||= (e) => k.name = e, + type: "text", + maxlength: "255" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]), + c(_, { label: S(d)("tasks_projects.settings.colour") }, { + default: E(() => [o("div", ur, [(g(), a(e, null, y(u, (e) => o("button", { + key: e, + type: "button", + class: f(["h-7 w-7 rounded-full border-2 transition", k.colour === e ? "border-heading" : "border-line-default"]), + style: p({ backgroundColor: e }), + "aria-label": e, + onClick: (t) => k.colour = e + }, null, 14, dr)), 64)), o("button", { + type: "button", + class: "rounded-md border border-line-default px-2 py-1 text-xs text-muted hover:bg-hover", + onClick: n[1] ||= (e) => k.colour = "" + }, x(S(d)("tasks_projects.settings.colour_none")), 1)])]), + _: 1 + }, 8, ["label"]), + o("div", fr, [o("label", pr, [c(v, { + modelValue: k.is_default, + "onUpdate:modelValue": n[2] ||= (e) => k.is_default = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + x(S(d)("tasks_projects.settings.is_default")), 1)]), o("label", mr, [c(v, { + modelValue: k.is_closed, + "onUpdate:modelValue": n[3] ||= (e) => k.is_closed = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + x(S(d)("tasks_projects.settings.is_closed")), 1)])]), + o("div", hr, [c(j, { + variant: "primary", + size: "sm", + disabled: T.value, + onClick: I + }, { + default: E(() => [s(x(S(d)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["disabled"]), c(j, { + variant: "primary-outline", + size: "sm", + onClick: P + }, { + default: E(() => [s(x(S(d)("tasks_projects.general.cancel")), 1)]), + _: 1 + })]) + ])) : (g(), a("div", gr, [ + o("span", { + class: f(["inline-block h-3 w-3 shrink-0 rounded-full", t.colour ? "" : "bg-line-default"]), + style: p(t.colour ? { backgroundColor: t.colour } : void 0) + }, null, 6), + o("span", _r, x(t.name), 1), + t.is_default ? (g(), r(F, { + key: 0, + class: "rounded-full bg-primary-50! text-primary-500!" + }, { + default: E(() => [s(x(S(d)("tasks_projects.settings.is_default")), 1)]), + _: 1 + })) : i("", !0), + t.is_closed ? (g(), r(F, { + key: 1, + class: "rounded-full bg-surface-tertiary! text-muted!" + }, { + default: E(() => [s(x(S(d)("tasks_projects.settings.is_closed")), 1)]), + _: 1 + })) : i("", !0), + o("div", vr, [ + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading disabled:opacity-40", + disabled: T.value || l === 0, + title: S(d)("tasks_projects.settings.move_up"), + "aria-label": S(d)("tasks_projects.settings.move_up"), + onClick: (e) => R(l, -1) + }, [c(z, { + name: "ChevronUpIcon", + class: "h-4 w-4" + })], 8, yr), + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading disabled:opacity-40", + disabled: T.value || l === m.value.length - 1, + title: S(d)("tasks_projects.settings.move_down"), + "aria-label": S(d)("tasks_projects.settings.move_down"), + onClick: (e) => R(l, 1) + }, [c(z, { + name: "ChevronDownIcon", + class: "h-4 w-4" + })], 8, br), + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-heading", + title: S(d)("tasks_projects.general.edit"), + "aria-label": S(d)("tasks_projects.general.edit"), + onClick: (e) => M(t) + }, [c(z, { + name: "PencilIcon", + class: "h-4 w-4" + })], 8, xr), + o("button", { + type: "button", + class: "rounded p-1 text-subtle hover:bg-hover hover:text-alert-error-text", + disabled: T.value, + title: S(d)("tasks_projects.general.delete"), + "aria-label": S(d)("tasks_projects.general.delete"), + onClick: (e) => L(t) + }, [c(z, { + name: "TrashIcon", + class: "h-4 w-4" + })], 8, Sr) + ]) + ]))]))), 128))])), O.value ? (g(), a("div", Cr, [ + c(_, { + label: S(d)("tasks_projects.settings.status_name"), + required: "" + }, { + default: E(() => [c(h, { + modelValue: k.name, + "onUpdate:modelValue": n[4] ||= (e) => k.name = e, + type: "text", + maxlength: "255" + }, null, 8, ["modelValue"])]), + _: 1 + }, 8, ["label"]), + c(_, { label: S(d)("tasks_projects.settings.colour") }, { + default: E(() => [o("div", wr, [(g(), a(e, null, y(u, (e) => o("button", { + key: e, + type: "button", + class: f(["h-7 w-7 rounded-full border-2 transition", k.colour === e ? "border-heading" : "border-line-default"]), + style: p({ backgroundColor: e }), + "aria-label": e, + onClick: (t) => k.colour = e + }, null, 14, Tr)), 64))])]), + _: 1 + }, 8, ["label"]), + o("div", Er, [o("label", Dr, [c(v, { + modelValue: k.is_default, + "onUpdate:modelValue": n[5] ||= (e) => k.is_default = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + x(S(d)("tasks_projects.settings.is_default")), 1)]), o("label", Or, [c(v, { + modelValue: k.is_closed, + "onUpdate:modelValue": n[6] ||= (e) => k.is_closed = e, + class: "flex" + }, null, 8, ["modelValue"]), s(" " + x(S(d)("tasks_projects.settings.is_closed")), 1)])]), + o("div", kr, [c(j, { + variant: "primary", + size: "sm", + disabled: T.value, + onClick: I + }, { + default: E(() => [s(x(S(d)("tasks_projects.general.save")), 1)]), + _: 1 + }, 8, ["disabled"]), c(j, { + variant: "primary-outline", + size: "sm", + onClick: P + }, { + default: E(() => [s(x(S(d)("tasks_projects.general.cancel")), 1)]), + _: 1 + })]) + ])) : C.value ? i("", !0) : (g(), r(j, { + key: 4, + variant: "primary-outline", + size: "sm", + class: "mt-4", + onClick: N + }, { + left: E((e) => [c(z, { + name: "PlusIcon", + class: f(e.class) + }, null, 8, ["class"])]), + default: E(() => [s(" " + x(S(d)("tasks_projects.settings.add_status")), 1)]), + _: 1 + }))]))]); + }; + } +}), jr = { class: "space-y-6" }, Mr = "/admin/settings/modules", Nr = /* @__PURE__ */ l({ + __name: "TimeSettingsPage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + router: {} + }, + setup(e) { + let t = U(); + return (n, r) => { + let i = b("BaseIcon"), o = b("BaseButton"), l = b("router-link"), u = b("BaseSettingCard"); + return g(), a("div", jr, [c(u, { + title: S(t)("tasks_projects.settings.general_title"), + description: S(t)("tasks_projects.settings.general_description") + }, { + action: E(() => [c(l, { to: Mr }, { + default: E(() => [c(o, { + variant: "primary-outline", + size: "sm" + }, { + right: E((e) => [c(i, { + name: "ArrowTopRightOnSquareIcon", + class: f(e.class) + }, null, 8, ["class"])]), + default: E(() => [s(" " + x(S(t)("tasks_projects.settings.open_module_settings")), 1)]), + _: 1 + })]), + _: 1 + })]), + _: 1 + }, 8, ["title", "description"]), c(u, { + title: S(t)("tasks_projects.settings.statuses_title"), + description: S(t)("tasks_projects.settings.statuses_description") + }, { + default: E(() => [c(Ar, { + client: e.client, + notify: e.notify + }, null, 8, ["client", "notify"])]), + _: 1 + }, 8, ["title", "description"])]); + }; + } +}), $ = "tasks-projects", Pr = `/admin/modules/${$}/time`; +function Fr(e) { + e.addMessages(an); + let t = (t, n) => { + e.notify(t, n); + }, n = () => { + e.router.push(Pr); + }; + e.registerPage({ + id: "time", + module: $, + path: "time", + component: Rr(e, rr), + meta: { + ability: `${$}:view-own-time`, + title: "tasks_projects.time.title" + } + }), e.registerHeaderAction({ + id: `${$}.timer-chip`, + priority: 30, + visible: () => X.running !== null, + component: l({ setup: () => () => d(rn, { + client: e.client, + notify: t, + onOpen: n + }) }) + }), e.registerCompanyLayoutOverlay({ + id: `${$}.quick-start`, + component: l({ setup: () => () => d(Zt, { + key: Q.companySession, + client: e.client, + notify: t, + enabled: !Q.adminMode, + onOpenTimesheet: n + }) }) + }), e.registerCompanySettingsPage({ + id: `${$}.settings`, + title: "tasks_projects.settings.title", + icon: "ClockIcon", + path: $, + priority: 70, + component: Rr(e, Nr) + }), e.on("bootstrap:completed", ({ adminMode: t }) => { + Ir(e, t); + }), e.on("company:changing", () => { + Lr(); + }), e.on("company:changed", ({ companyId: t }) => { + Ir(e, t === null); + }); +} +async function Ir(e, t) { + if (Yn(t), t) { + Lr(); + return; + } + await qn(e.client), await X.refresh(e.client); +} +function Lr() { + X.reset(), et(), Jn(); +} +function Rr(e, t) { + return l({ setup: (n, { attrs: r }) => () => d(t, { + ...r, + client: e.client, + notify: (t, n) => { + e.notify(t, n); + }, + router: e.router + }) }); +} +//#endregion +//#region resources/js/init.ts +var zr = "tasks-projects"; window.InvoiceShelf.booting((e, t, n) => { - n.addMessages(E), n.registerPage({ + n.addMessages(A), n.registerPage({ id: "projects", - module: $, + module: zr, path: "", - component: fe(n, de), + component: Br(n, we), meta: { - ability: `${$}:view-project`, + ability: `${zr}:view-project`, title: "tasks_projects.projects.title" } - }); + }), Fr(n); }); -function fe(e, t) { - return c({ setup: (n, { attrs: r }) => () => u(t, { +function Br(e, t) { + return l({ setup: (n, { attrs: r }) => () => d(t, { ...r, client: e.client, notify: (t, n) => { diff --git a/dist/style.css b/dist/style.css index d1084a1..fcc4fe8 100644 --- a/dist/style.css +++ b/dist/style.css @@ -1,3 +1,3 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer utilities{.relative{position:relative}.mt-2{margin-top:calc(var(--spacing,.25rem) * 2)}.mt-3{margin-top:calc(var(--spacing,.25rem) * 3)}.mt-5{margin-top:calc(var(--spacing,.25rem) * 5)}.mr-3{margin-right:calc(var(--spacing,.25rem) * 3)}.mb-4{margin-bottom:calc(var(--spacing,.25rem) * 4)}.block{display:block}.flex{display:flex}.inline-block{display:inline-block}.h-2\.5{height:calc(var(--spacing,.25rem) * 2.5)}.h-5{height:calc(var(--spacing,.25rem) * 5)}.h-6{height:calc(var(--spacing,.25rem) * 6)}.h-7{height:calc(var(--spacing,.25rem) * 7)}.h-16{height:calc(var(--spacing,.25rem) * 16)}.w-2\.5{width:calc(var(--spacing,.25rem) * 2.5)}.w-5{width:calc(var(--spacing,.25rem) * 5)}.w-6{width:calc(var(--spacing,.25rem) * 6)}.w-7{width:calc(var(--spacing,.25rem) * 7)}.w-16{width:calc(var(--spacing,.25rem) * 16)}.w-full{width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.cursor-pointer{cursor:pointer}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-end{justify-content:flex-end}.gap-2{gap:calc(var(--spacing,.25rem) * 2)}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing,.25rem) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing,.25rem) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-5>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing,.25rem) * 5) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing,.25rem) * 5) * calc(1 - var(--tw-space-x-reverse)))}.rounded-full{border-radius:2147483647px}.rounded-md{border-radius:var(--radius-md,.375rem)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-heading{border-color:var(--color-heading)}.border-line-default{border-color:var(--color-line-default)}.bg-line-default{background-color:var(--color-line-default)}.bg-primary-50\!{background-color:var(--color-primary-50)!important}.bg-surface-tertiary\!{background-color:var(--color-surface-tertiary)!important}.px-2{padding-inline:calc(var(--spacing,.25rem) * 2)}.px-6{padding-inline:calc(var(--spacing,.25rem) * 6)}.py-1{padding-block:var(--spacing,.25rem)}.py-4{padding-block:calc(var(--spacing,.25rem) * 4)}.py-6{padding-block:calc(var(--spacing,.25rem) * 6)}.text-right{text-align:right}.text-sm{font-size:var(--text-sm,.875rem);line-height:var(--tw-leading,var(--text-sm--line-height,calc(1.25 / .875)))}.text-xs{font-size:var(--text-xs,.75rem);line-height:var(--tw-leading,var(--text-xs--line-height,calc(1 / .75)))}.font-medium{--tw-font-weight:var(--font-weight-medium,500);font-weight:var(--font-weight-medium,500)}.font-normal{--tw-font-weight:var(--font-weight-normal,400);font-weight:var(--font-weight-normal,400)}.text-heading{color:var(--color-heading)}.text-muted{color:var(--color-muted)}.text-muted\!{color:var(--color-muted)!important}.text-primary-500\!{color:var(--color-primary-500)!important}.text-subtle{color:var(--color-subtle)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s))}@media (hover:hover){.group-hover\:text-muted:is(:where(.group):hover *){color:var(--color-muted)}.hover\:bg-hover:hover{background-color:var(--color-hover)}.hover\:text-body:hover{color:var(--color-body)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.right-6{right:calc(var(--spacing,.25rem) * 6)}.bottom-20{bottom:calc(var(--spacing,.25rem) * 20)}.z-40{z-index:40}.float-left{float:left}.m-0{margin:0}.mt-1{margin-top:var(--spacing,.25rem)}.mt-2{margin-top:calc(var(--spacing,.25rem) * 2)}.mt-3{margin-top:calc(var(--spacing,.25rem) * 3)}.mt-4{margin-top:calc(var(--spacing,.25rem) * 4)}.mt-5{margin-top:calc(var(--spacing,.25rem) * 5)}.mt-6{margin-top:calc(var(--spacing,.25rem) * 6)}.mr-3{margin-right:calc(var(--spacing,.25rem) * 3)}.-mb-px{margin-bottom:-1px}.mb-4{margin-bottom:calc(var(--spacing,.25rem) * 4)}.ml-1{margin-left:var(--spacing,.25rem)}.ml-2{margin-left:calc(var(--spacing,.25rem) * 2)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-1\.5{height:calc(var(--spacing,.25rem) * 1.5)}.h-2{height:calc(var(--spacing,.25rem) * 2)}.h-2\.5{height:calc(var(--spacing,.25rem) * 2.5)}.h-3{height:calc(var(--spacing,.25rem) * 3)}.h-4{height:calc(var(--spacing,.25rem) * 4)}.h-5{height:calc(var(--spacing,.25rem) * 5)}.h-6{height:calc(var(--spacing,.25rem) * 6)}.h-7{height:calc(var(--spacing,.25rem) * 7)}.h-8{height:calc(var(--spacing,.25rem) * 8)}.h-16{height:calc(var(--spacing,.25rem) * 16)}.max-h-48{max-height:calc(var(--spacing,.25rem) * 48)}.min-h-40{min-height:calc(var(--spacing,.25rem) * 40)}.w-1\.5{width:calc(var(--spacing,.25rem) * 1.5)}.w-2{width:calc(var(--spacing,.25rem) * 2)}.w-2\.5{width:calc(var(--spacing,.25rem) * 2.5)}.w-3{width:calc(var(--spacing,.25rem) * 3)}.w-4{width:calc(var(--spacing,.25rem) * 4)}.w-5{width:calc(var(--spacing,.25rem) * 5)}.w-6{width:calc(var(--spacing,.25rem) * 6)}.w-7{width:calc(var(--spacing,.25rem) * 7)}.w-16{width:calc(var(--spacing,.25rem) * 16)}.w-80{width:calc(var(--spacing,.25rem) * 80)}.w-full{width:100%}.max-w-32{max-width:calc(var(--spacing,.25rem) * 32)}.max-w-64{max-width:calc(var(--spacing,.25rem) * 64)}.max-w-\[calc\(100vw-3rem\)\]{max-width:calc(100vw - 3rem)}.min-w-0{min-width:0}.flex-1{flex:1}.shrink-0{flex-shrink:0}.animate-pulse{animation:var(--animate-pulse,pulse 2s cubic-bezier(.4, 0, .6, 1) infinite)}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-end{align-items:flex-end}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-1{gap:var(--spacing,.25rem)}.gap-2{gap:calc(var(--spacing,.25rem) * 2)}.gap-3{gap:calc(var(--spacing,.25rem) * 3)}.gap-6{gap:calc(var(--spacing,.25rem) * 6)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(var(--spacing,.25rem) * var(--tw-space-y-reverse));margin-block-end:calc(var(--spacing,.25rem) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing,.25rem) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing,.25rem) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-3>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing,.25rem) * 3) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing,.25rem) * 3) * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-5>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing,.25rem) * 5) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing,.25rem) * 5) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-line-light>:not(:last-child)){border-color:var(--color-line-light)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:2147483647px}.rounded-lg{border-radius:var(--radius-lg,.5rem)}.rounded-md{border-radius:var(--radius-md,.375rem)}.rounded-xl{border-radius:var(--radius-xl,.75rem)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-heading{border-color:var(--color-heading)}.border-line-default{border-color:var(--color-line-default)}.border-line-light{border-color:var(--color-line-light)}.border-primary-400{border-color:var(--color-primary-400)}.border-primary-500{border-color:var(--color-primary-500)}.border-transparent{border-color:#0000}.bg-alert-warning-bg{background-color:var(--color-alert-warning-bg)}.bg-btn-primary{background-color:var(--color-btn-primary)}.bg-hover-strong{background-color:var(--color-hover-strong)}.bg-line-default{background-color:var(--color-line-default)}.bg-line-strong{background-color:var(--color-line-strong)}.bg-primary-50\!{background-color:var(--color-primary-50)!important}.bg-primary-500{background-color:var(--color-primary-500)}.bg-status-green{background-color:var(--color-status-green)}.bg-surface{background-color:var(--color-surface)}.bg-surface-tertiary\!{background-color:var(--color-surface-tertiary)!important}.bg-white{background-color:var(--color-white,#fff)}.bg-white\/20{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.bg-white\/20{background-color:color-mix(in oklab, var(--color-white,#fff) 20%, transparent)}}.p-1{padding:var(--spacing,.25rem)}.p-3{padding:calc(var(--spacing,.25rem) * 3)}.px-1{padding-inline:var(--spacing,.25rem)}.px-2{padding-inline:calc(var(--spacing,.25rem) * 2)}.px-3{padding-inline:calc(var(--spacing,.25rem) * 3)}.px-4{padding-inline:calc(var(--spacing,.25rem) * 4)}.px-6{padding-inline:calc(var(--spacing,.25rem) * 6)}.py-1{padding-block:var(--spacing,.25rem)}.py-1\.5{padding-block:calc(var(--spacing,.25rem) * 1.5)}.py-2{padding-block:calc(var(--spacing,.25rem) * 2)}.py-3{padding-block:calc(var(--spacing,.25rem) * 3)}.py-4{padding-block:calc(var(--spacing,.25rem) * 4)}.py-6{padding-block:calc(var(--spacing,.25rem) * 6)}.pb-3{padding-bottom:calc(var(--spacing,.25rem) * 3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-2xl{font-size:var(--text-2xl,1.5rem);line-height:var(--tw-leading,var(--text-2xl--line-height,calc(2 / 1.5)))}.text-lg{font-size:var(--text-lg,1.125rem);line-height:var(--tw-leading,var(--text-lg--line-height,calc(1.75 / 1.125)))}.text-sm{font-size:var(--text-sm,.875rem);line-height:var(--tw-leading,var(--text-sm--line-height,calc(1.25 / .875)))}.text-xs{font-size:var(--text-xs,.75rem);line-height:var(--tw-leading,var(--text-xs--line-height,calc(1 / .75)))}.text-\[11px\]{font-size:11px}.font-medium{--tw-font-weight:var(--font-weight-medium,500);font-weight:var(--font-weight-medium,500)}.font-normal{--tw-font-weight:var(--font-weight-normal,400);font-weight:var(--font-weight-normal,400)}.font-semibold{--tw-font-weight:var(--font-weight-semibold,600);font-weight:var(--font-weight-semibold,600)}.tracking-wide{--tw-tracking:var(--tracking-wide,.025em);letter-spacing:var(--tracking-wide,.025em)}.text-alert-warning-text{color:var(--color-alert-warning-text)}.text-body{color:var(--color-body)}.text-heading{color:var(--color-heading)}.text-muted{color:var(--color-muted)}.text-muted\!{color:var(--color-muted)!important}.text-primary-500{color:var(--color-primary-500)}.text-primary-500\!{color:var(--color-primary-500)!important}.text-subtle{color:var(--color-subtle)}.text-white{color:var(--color-white,#fff)}.uppercase{text-transform:uppercase}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.outline-hidden{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function,cubic-bezier(.4, 0, .2, 1)));transition-duration:var(--tw-duration,var(--default-transition-duration,.15s))}@media (hover:hover){.group-hover\:text-muted:is(:where(.group):hover *){color:var(--color-muted)}.hover\:border-line-strong:hover{border-color:var(--color-line-strong)}.hover\:bg-btn-primary-hover:hover{background-color:var(--color-btn-primary-hover)}.hover\:bg-hover:hover{background-color:var(--color-hover)}.hover\:bg-white\/20:hover{background-color:#fff3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/20:hover{background-color:color-mix(in oklab, var(--color-white,#fff) 20%, transparent)}}.hover\:text-alert-error-text:hover{color:var(--color-alert-error-text)}.hover\:text-body:hover{color:var(--color-body)}.hover\:text-heading:hover{color:var(--color-heading)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:border-primary-400:focus{border-color:var(--color-primary-400)}.focus\:ring-1:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-primary-400:focus{--tw-ring-color:var(--color-primary-400)}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:flex{display:flex}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (width>=48rem){.md\:h-9{height:calc(var(--spacing,.25rem) * 9)}.md\:px-3{padding-inline:calc(var(--spacing,.25rem) * 3)}}@media (width>=64rem){.lg\:block{display:block}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (width>=80rem){.xl\:grid-cols-7{grid-template-columns:repeat(7,minmax(0,1fr))}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@keyframes pulse{50%{opacity:.5}} /*$vite$:1*/ \ No newline at end of file From ee9ab80f3d8e8f3cc204229ee531e6a1a6c090f3 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:40:53 +0200 Subject: [PATCH 5/6] refactor(ui): drop the two time helpers nothing calls `dayOf` and `taskName` were written for callers that ended up using the labelled variants instead. Dead code in a shipped bundle is weight the browser downloads for nothing. --- resources/js/stores/tasks.ts | 5 ----- resources/js/support/time.ts | 5 ----- 2 files changed, 10 deletions(-) diff --git a/resources/js/stores/tasks.ts b/resources/js/stores/tasks.ts index c281c8c..70bb972 100644 --- a/resources/js/stores/tasks.ts +++ b/resources/js/stores/tasks.ts @@ -18,11 +18,6 @@ const pending = new Set() /** How many name lookups may be in flight at once. */ const BATCH_SIZE = 5 -/** The cached name of a task, or null while it is still unknown. */ -export function taskName(id: number | null): string | null { - return id === null ? null : (names[id] ?? null) -} - /** The cached name, or a stable `#id` placeholder to render meanwhile. */ export function taskLabel(id: number | null): string { if (id === null) { diff --git a/resources/js/support/time.ts b/resources/js/support/time.ts index cadd8c1..72f73e4 100644 --- a/resources/js/support/time.ts +++ b/resources/js/support/time.ts @@ -107,11 +107,6 @@ export function addMinutes(instant: string, minutes: number): string { return date.toISOString() } -/** Local midnight of the `Y-m-d` given, or of today when it is unreadable. */ -export function dayOf(date: string): Date { - return parseDateString(date) ?? startOfDay(new Date()) -} - /** * The first day of the week `date` falls in. * From 4878146fb1ea4830551b1efa834c7cfa9f14d193 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:55:18 +0200 Subject: [PATCH 6/6] feat: place the Projects sidebar entry first among modules Registry::registerMenu accepts a priority (lower sorts first inside the group, default 100); the entry now sets 10 so Projects precedes modules that keep the default. --- app/Support/ModuleRegistration.php | 2 ++ tests/Feature/ModuleRegistrationTest.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/Support/ModuleRegistration.php b/app/Support/ModuleRegistration.php index aa51851..5f14ed3 100644 --- a/app/Support/ModuleRegistration.php +++ b/app/Support/ModuleRegistration.php @@ -17,6 +17,8 @@ public static function register(string $modulePath): void 'title' => 'tasksprojects::menu.title', 'link' => '/admin/modules/tasks-projects', 'icon' => 'ClipboardDocumentListIcon', + // Lower sorts first within the sidebar group; official modules use 10, 20, ... + 'priority' => 10, ]); Registry::registerSettings('tasks-projects', [ diff --git a/tests/Feature/ModuleRegistrationTest.php b/tests/Feature/ModuleRegistrationTest.php index b20de87..6244183 100644 --- a/tests/Feature/ModuleRegistrationTest.php +++ b/tests/Feature/ModuleRegistrationTest.php @@ -25,7 +25,7 @@ public function test_it_registers_a_local_script_style_sidebar_entry_and_setting self::assertSame([ 'group' => 'modules', 'group_label' => 'navigation.modules', - 'priority' => 100, + 'priority' => 10, 'title' => 'tasksprojects::menu.title', 'link' => '/admin/modules/tasks-projects', 'icon' => 'ClipboardDocumentListIcon',