From 591c031a3fa4f9b85b2455de2f91d0714cad7296 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 09:45:53 +0200 Subject: [PATCH 01/12] feat(ui): carry a task's time with the task, and one clock for the page Every screen that shows a task now shows how long it has taken, what is still unbilled and who has a clock running on it, so `TaskResource` carries a `time` block and the rows carry it with them. Every read of that block goes through `taskTime()`, which merges the payload over an empty summary: a server that predates the block still renders a list, it just reports nothing logged. Two pieces of shared state come with it. `taskVersion` is bumped by every write, including a timer that started or stopped, and every list, board and grid watches it; without it a clock started on the task page would leave the list behind it showing yesterday's total. `patchTime` is what makes pressing play feel instant: the row the button sits in came from a request that will not be repeated for a second, so the running entry is written locally and replaced when the fresh answer lands. The per-second reactivity is one interval for the whole page. A board can show a dozen running cards and a time log a dozen running rows; `useNow()` hands out one ref and counts its subscribers, so the cost is one wake-up a second and only the components reading it re-render. The store holds the same subscription while the caller's own timer runs, which is what keeps the header chip ticking on a screen with no rows at all. `startOnTask` and `stopOnTask` use the new per-task routes. They name the task, so a stale row cannot stop a timer that has since moved: the server answers `timer_mismatch` and the row reloads. `errorCode()` reads the `{ message, error }` shape structurally, because the sentence is translated and the key is not. `support/filters.ts` makes the address bar the filter state. All four values are strings, which is what a query string holds, so a view switch, a reload, a back button and a pasted link all describe the same screen with no conversion but the one at the endpoint. `PATHS` and `ROUTES` in `support/page.ts` stop each screen from spelling the module prefix out again. --- resources/js/api/board.ts | 73 ++++++++++++- resources/js/stores/session.ts | 53 ++++++++- resources/js/stores/tasks.ts | 82 +++++++++++++- resources/js/stores/timer.ts | 185 ++++++++++++++++++++++++++++---- resources/js/support/filters.ts | 158 +++++++++++++++++++++++++++ resources/js/support/http.ts | 23 ++++ resources/js/support/page.ts | 40 ++++++- resources/js/support/time.ts | 12 ++- resources/js/types/settings.ts | 17 +++ resources/js/types/task.ts | 54 +++++++++- 10 files changed, 663 insertions(+), 34 deletions(-) create mode 100644 resources/js/support/filters.ts diff --git a/resources/js/api/board.ts b/resources/js/api/board.ts index 573f84e..7cc24f1 100644 --- a/resources/js/api/board.ts +++ b/resources/js/api/board.ts @@ -5,7 +5,14 @@ import type { Paginated, Wrapped } from '@/types/api' import type { BoardColumn, BoardParams } from '@/types/board' import type { Project } from '@/types/project' import type { ProjectMember, ProjectMemberInput } from '@/types/project-member' -import type { Task, TaskInput, TaskListParams, TaskMoveInput } from '@/types/task' +import type { + Task, + TaskBulkInput, + TaskBulkResult, + TaskInput, + TaskListParams, + TaskMoveInput, +} from '@/types/task' import type { TaskStatus } from '@/types/task-status' import type { TimeEntry, TimeEntryListParams } from '@/types/time-entry' @@ -20,6 +27,10 @@ export const BOARD_API = { tasks: `${BASE}/tasks`, task: (id: number): string => `${BASE}/tasks/${id}`, moveTask: (id: number): string => `${BASE}/tasks/${id}/move`, + startTask: (id: number): string => `${BASE}/tasks/${id}/start`, + stopTask: (id: number): string => `${BASE}/tasks/${id}/stop`, + taskTimeLog: (id: number): string => `${BASE}/tasks/${id}/time-log`, + bulkTasks: `${BASE}/tasks/bulk`, taskStatuses: `${BASE}/task-statuses`, timeEntries: `${BASE}/time-entries`, projectMembers: (projectId: number): string => `${BASE}/projects/${projectId}/members`, @@ -73,6 +84,66 @@ export async function deleteTask(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(BOARD_API.task(id)) + + return data.data +} + +/** + * Put the caller's clock on a task. + * + * Answers 409 `timer_already_running` when their timer is on another task, + * which is a question for the caller rather than a failure: the run control + * offers to stop the other one first. + */ +export async function startTask( + client: AxiosInstance, + id: number, + description: string | null = null, +): Promise { + const body = description === null ? {} : { description } + const { data } = await client.post>(BOARD_API.startTask(id), body) + + return data.data +} + +/** Close the caller's running entry on a task. 409 `timer_mismatch` if it moved. */ +export async function stopTask(client: AxiosInstance, id: number): Promise { + const { data } = await client.post>(BOARD_API.stopTask(id)) + + return data.data +} + +/** + * Every entry logged against one task, running first and then newest. + * + * A caller who may not see other members' time gets their own rows, so the + * grid renders either way and never has to ask which case it is in. + */ +export async function fetchTaskTimeLog(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(BOARD_API.taskTimeLog(id)) + + return data.data ?? [] +} + +/** + * Apply one action to a selection of tasks. + * + * The endpoint is partial by design: it reports how many it changed and names + * the ones it refused, so a locked task in the selection does not sink the + * rest of it. + */ +export async function bulkTasks( + client: AxiosInstance, + input: TaskBulkInput, +): Promise { + const { data } = await client.post(BOARD_API.bulkTasks, input) + + return { updated: data?.updated ?? 0, failed: data?.failed ?? [] } +} + /** * Drop a task between two neighbours of a column. * diff --git a/resources/js/stores/session.ts b/resources/js/stores/session.ts index 4f153b9..4705614 100644 --- a/resources/js/stores/session.ts +++ b/resources/js/stores/session.ts @@ -1,7 +1,7 @@ import { reactive } from 'vue' import type { AxiosInstance } from 'axios' import { fetchCurrentUserId, fetchTimeSettings } from '@/api/time' -import type { ModuleSettings } from '@/types/settings' +import type { ModuleSettings, RoundingDirection } from '@/types/settings' /** * What the time screens need to know about the current session. @@ -20,9 +20,19 @@ import type { ModuleSettings } from '@/types/settings' export const DEFAULT_SETTINGS: ModuleSettings = { default_rate: 0, rounding_minutes: 1, + rounding_direction: 'nearest', week_start: 1, members_see_all_time: false, - rounding_increments: [1, 6, 15, 30], + auto_start_tasks: false, + lock_invoiced_tasks: false, + hide_invoiced_on_board: false, + invoice_project_heading: false, + invoice_task_description: true, + invoice_entry_dates: true, + invoice_entry_times: false, + invoice_entry_hours: true, + invoice_entry_descriptions: false, + rounding_increments: [1, 5, 6, 15, 30, 60], } interface SessionState { @@ -92,8 +102,30 @@ function normaliseSettings(settings: ModuleSettings | null): ModuleSettings { return { default_rate: numberOr(settings.default_rate, DEFAULT_SETTINGS.default_rate), rounding_minutes: numberOr(settings.rounding_minutes, DEFAULT_SETTINGS.rounding_minutes), + rounding_direction: directionOr(settings.rounding_direction), week_start: weekStartOr(settings.week_start), members_see_all_time: settings.members_see_all_time === true, + auto_start_tasks: flagOr(settings.auto_start_tasks, DEFAULT_SETTINGS.auto_start_tasks), + lock_invoiced_tasks: flagOr(settings.lock_invoiced_tasks, DEFAULT_SETTINGS.lock_invoiced_tasks), + hide_invoiced_on_board: flagOr( + settings.hide_invoiced_on_board, + DEFAULT_SETTINGS.hide_invoiced_on_board, + ), + invoice_project_heading: flagOr( + settings.invoice_project_heading, + DEFAULT_SETTINGS.invoice_project_heading, + ), + invoice_task_description: flagOr( + settings.invoice_task_description, + DEFAULT_SETTINGS.invoice_task_description, + ), + invoice_entry_dates: flagOr(settings.invoice_entry_dates, DEFAULT_SETTINGS.invoice_entry_dates), + invoice_entry_times: flagOr(settings.invoice_entry_times, DEFAULT_SETTINGS.invoice_entry_times), + invoice_entry_hours: flagOr(settings.invoice_entry_hours, DEFAULT_SETTINGS.invoice_entry_hours), + invoice_entry_descriptions: flagOr( + settings.invoice_entry_descriptions, + DEFAULT_SETTINGS.invoice_entry_descriptions, + ), rounding_increments: increments.length > 0 ? increments : DEFAULT_SETTINGS.rounding_increments, } } @@ -102,6 +134,23 @@ function numberOr(value: unknown, fallback: number): number { return typeof value === 'number' && Number.isFinite(value) ? value : fallback } +/** + * A toggle the server sent, or the documented default. + * + * An older server omits these keys entirely, which is not the same answer as + * "off": a missing `invoice_task_description` still means the description is + * written, because that is what the module promises when nobody has chosen. + */ +function flagOr(value: unknown, fallback: boolean): boolean { + return typeof value === 'boolean' ? value : fallback +} + +function directionOr(value: unknown): RoundingDirection { + return value === 'up' || value === 'down' || value === 'nearest' + ? value + : DEFAULT_SETTINGS.rounding_direction +} + function weekStartOr(value: unknown): number { return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 6 ? value diff --git a/resources/js/stores/tasks.ts b/resources/js/stores/tasks.ts index 70bb972..d68f4cf 100644 --- a/resources/js/stores/tasks.ts +++ b/resources/js/stores/tasks.ts @@ -1,6 +1,8 @@ -import { reactive } from 'vue' +import { reactive, ref } from 'vue' +import type { Ref } from 'vue' import type { AxiosInstance } from 'axios' import { fetchTask } from '@/api/time' +import type { Task, TaskTime } from '@/types/task' import type { TaskSummary } from '@/types/task-summary' /** @@ -18,6 +20,41 @@ const pending = new Set() /** How many name lookups may be in flight at once. */ const BATCH_SIZE = 5 +/** + * What a task's time block says when the server did not send one. + * + * Every reader goes through `taskTime()`, so a row from an older server still + * renders: it just reports nothing logged rather than blanking the column. + */ +const EMPTY_TIME: TaskTime = { + logged_minutes: 0, + billable_minutes: 0, + unbilled_minutes: 0, + unbilled_amount: 0, + invoiced: 'none', + running: [], +} + +/** + * How many writes have happened, for the lists and boards to watch. + * + * A task written on one screen changes what another shows: starting a clock on + * the task page changes the row on the list behind it, and a bulk status + * change moves cards on the board. Rather than wiring every screen to every + * other, each one watches this counter and refetches. + */ +const version = ref(0) + +/** + * Time blocks a screen has written ahead of the server's answer. + * + * Pressing play has to look instant, but the row the button sits in came from + * a list request that will not be repeated for a second or two. The override + * is merged over whatever the payload carried and is dropped when the fresh + * answer arrives. + */ +const timePatches = reactive>>({}) + /** The cached name, or a stable `#id` placeholder to render meanwhile. */ export function taskLabel(id: number | null): string { if (id === null) { @@ -28,7 +65,7 @@ export function taskLabel(id: number | null): string { } /** Remember a task the caller already holds, so no lookup is needed. */ -export function rememberTask(task: TaskSummary | null | undefined): void { +export function rememberTask(task: TaskSummary | Task | null | undefined): void { if (task && typeof task.id === 'number' && typeof task.name === 'string') { names[task.id] = task.name } @@ -64,11 +101,52 @@ export async function ensureTaskNames(client: AxiosInstance, ids: number[]): Pro } } +/** The counter every list and board watches to know a refetch is due. */ +export const taskVersion: Ref = version + +/** Say that a task was written, so every open list and board reloads. */ +export function bumpTaskVersion(): void { + version.value += 1 +} + +/** Show a task's time as something else until the server confirms it. */ +export function patchTime(taskId: number, partial: Partial): void { + timePatches[taskId] = { ...(timePatches[taskId] ?? {}), ...partial } +} + +/** Forget one optimistic patch, because a fresh payload has replaced it. */ +export function clearTimePatch(taskId: number): void { + delete timePatches[taskId] +} + +/** + * A task's time summary: what the payload carried, under what a screen has + * written optimistically, over the empty summary an older server implies. + */ +export function taskTime(task: Pick | null | undefined): TaskTime { + if (!task || typeof task.id !== 'number') { + return EMPTY_TIME + } + + const sent = task.time ?? EMPTY_TIME + + return { + ...EMPTY_TIME, + ...sent, + running: Array.isArray(sent.running) ? sent.running : [], + ...(timePatches[task.id] ?? {}), + } +} + /** Drop everything: task ids belong to one company. */ export function resetTaskNames(): void { for (const key of Object.keys(names)) { delete names[Number(key)] } + for (const key of Object.keys(timePatches)) { + delete timePatches[Number(key)] + } + pending.clear() } diff --git a/resources/js/stores/timer.ts b/resources/js/stores/timer.ts index 80fc19c..af9066e 100644 --- a/resources/js/stores/timer.ts +++ b/resources/js/stores/timer.ts @@ -1,21 +1,18 @@ -import { reactive } from 'vue' +import { computed, onScopeDispose, reactive } from 'vue' +import type { ComputedRef } from 'vue' import type { AxiosInstance } from 'axios' -import { - discardTimer, - fetchTimer, - startTimer, - stopTimer, -} from '@/api/time' +import { startTask, stopTask } from '@/api/board' +import { discardTimer, fetchTimer, startTimer, stopTimer } from '@/api/time' import { errorMessage } from '@/support/errors' -import { isConflict } from '@/support/http' -import { secondsSince } from '@/support/time' +import { errorCode, isConflict } from '@/support/http' +import { secondsBetween } from '@/support/time' import type { Translate } from '@/support/i18n' import type { TimeEntry } from '@/types/time-entry' -import { ensureTaskNames } from './tasks' +import { bumpTaskVersion, ensureTaskNames, patchTime } from './tasks' /** - * The running timer, shared by the header chip, the quick-start launcher and - * the timesheet. + * The running timer, shared by the header chip, the quick-start launcher, the + * task rows and the time log. * * 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 @@ -39,51 +36,83 @@ export interface TimerFeedback { 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, }) +/** + * One clock for every live duration on the screen. + * + * A board can show a dozen running rows; one interval driving one ref keeps + * that at a single wake-up per second, and only the components that read it + * re-render. It runs while anything is subscribed and while the caller's own + * timer is going, and stops as soon as neither is true. + */ +const clock = reactive({ now: Date.now() }) + let ticker: ReturnType | undefined +let subscribers = 0 function tick(): void { - state.elapsedSeconds = state.running === null ? 0 : secondsSince(state.running.started_at) + clock.now = Date.now() } -function startTicking(): void { - tick() +function subscribeClock(): void { + subscribers += 1 if (ticker === undefined) { + tick() ticker = setInterval(tick, 1000) } } -function stopTicking(): void { - if (ticker !== undefined) { +function unsubscribeClock(): void { + subscribers = Math.max(0, subscribers - 1) + + if (subscribers === 0 && ticker !== undefined) { clearInterval(ticker) ticker = undefined } +} - state.elapsedSeconds = 0 +/** + * Read the shared clock for as long as this scope lives. + * + * Call it from `setup` in any component that renders a live duration; the + * subscription is released when the component goes away. + */ +export function useNow(): ComputedRef { + subscribeClock() + onScopeDispose(unsubscribeClock, true) + + return computed(() => clock.now) } +/** Whether the store itself is holding the clock open for its own entry. */ +let holdingClock = false + /** 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() + if (holdingClock) { + holdingClock = false + unsubscribeClock() + } return } - startTicking() + if (!holdingClock) { + holdingClock = true + subscribeClock() + } if (client && typeof state.running.task_id === 'number') { void ensureTaskNames(client, [state.running.task_id]) @@ -94,15 +123,29 @@ function report(feedback: TimerFeedback | undefined, error: unknown, key: string feedback?.notify('error', errorMessage(error, feedback.t(key))) } +/** Say that the caller's clock is now on this task, before the list agrees. */ +function claim(entry: TimeEntry): void { + patchTime(entry.task_id, { + running: [{ entry_id: entry.id, user_id: entry.user_id, started_at: entry.started_at }], + }) +} + export const timerStore = { /** The running entry, or null when the clock is not running. */ get running(): TimeEntry | null { return state.running }, + /** The task the caller's clock is on, or null when it is not running. */ + get runningTaskId(): number | null { + const taskId = state.running?.task_id + + return typeof taskId === 'number' ? taskId : null + }, + /** Seconds since the running entry started, recomputed every second. */ get elapsedSeconds(): number { - return state.elapsedSeconds + return state.running === null ? 0 : secondsBetween(state.running.started_at, clock.now) }, /** True while a timer request is in flight. */ @@ -110,6 +153,11 @@ export const timerStore = { return state.busy }, + /** Whether the caller's own clock is on this task. */ + isRunningOn(taskId: number): boolean { + return state.running !== null && state.running.task_id === taskId + }, + /** Read the caller's running entry from the server. */ async refresh(client: AxiosInstance): Promise { try { @@ -142,6 +190,8 @@ export const timerStore = { const entry = await startTimer(client, { task_id: taskId, description }) adopt(entry, client) + claim(entry) + bumpTaskVersion() return entry } catch (error: unknown) { @@ -158,18 +208,63 @@ export const timerStore = { } }, + /** + * Start the clock through the task's own route. + * + * Same effect as `start`, but the server answers `timer_already_running` + * when the caller's clock is on a different task, which the run control + * turns into "stop that one and start this one" rather than a dead end. + */ + async startOnTask( + client: AxiosInstance, + taskId: number, + description: string | null = null, + feedback?: TimerFeedback, + ): Promise { + if (state.busy) { + return null + } + + state.busy = true + + try { + const entry = await startTask(client, taskId, description) + + adopt(entry, client) + claim(entry) + bumpTaskVersion() + + return entry + } catch (error: unknown) { + if (errorCode(error) === 'timer_already_running') { + 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 } + const taskId = state.running.task_id + state.busy = true try { const entry = await stopTimer(client) adopt(null) + patchTime(taskId, { running: [] }) + bumpTaskVersion() return entry } catch (error: unknown) { @@ -182,17 +277,61 @@ export const timerStore = { } }, + /** + * Close the caller's entry on one named task. + * + * The task is named so a stale row cannot stop a timer that has since moved + * elsewhere: the server answers `timer_mismatch` and the row reloads. + */ + async stopOnTask( + client: AxiosInstance, + taskId: number, + feedback?: TimerFeedback, + ): Promise { + if (state.busy) { + return null + } + + state.busy = true + + try { + const entry = await stopTask(client, taskId) + + adopt(null) + patchTime(taskId, { running: [] }) + bumpTaskVersion() + + return entry + } catch (error: unknown) { + if (errorCode(error) === 'timer_mismatch') { + feedback?.notify('warning', feedback.t('tasks_projects.timer.mismatch')) + } else { + 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 } + const taskId = state.running.task_id + state.busy = true try { await discardTimer(client) adopt(null) + patchTime(taskId, { running: [] }) + bumpTaskVersion() return true } catch (error: unknown) { diff --git a/resources/js/support/filters.ts b/resources/js/support/filters.ts new file mode 100644 index 0000000..b4472fd --- /dev/null +++ b/resources/js/support/filters.ts @@ -0,0 +1,158 @@ +import type { LocationQuery, LocationQueryRaw } from 'vue-router' +import type { SortParams } from '@/api' +import type { TaskSortKey } from '@/api/board' +import type { TaskListParams } from '@/types/task' + +/** + * The Tasks screen's filters, as the address bar carries them. + * + * Everything is a string because that is what a query string holds and what a + * link has to round-trip: a view switch, a reload and a bookmark all go + * through the URL, so keeping one representation removes every conversion but + * the one at the edge. + */ +export interface TaskFilterState { + /** A project id, or '' for every project. */ + project: string + /** A member id, or '' for everyone. */ + user: string + /** A status id, or the pseudo values below, or '' for every status. */ + status: string + search: string +} + +/** The two status values that are not a board column. */ +export const INVOICED_FILTERS = ['uninvoiced', 'invoiced'] as const + +export type InvoicedFilter = (typeof INVOICED_FILTERS)[number] + +export const EMPTY_FILTERS: TaskFilterState = { + project: '', + user: '', + status: '', + search: '', +} + +/** Whether a status filter names the invoicing state rather than a column. */ +export function isInvoicedFilter(status: string): status is InvoicedFilter { + return status === 'uninvoiced' || status === 'invoiced' +} + +/** The filters a route carries, with anything unrecognised dropped. */ +export function readFilters(query: LocationQuery): TaskFilterState { + return { + project: numeric(query.project), + user: numeric(query.user), + status: statusOf(query.status), + search: single(query.search).slice(0, 200), + } +} + +/** + * The query a link should carry. + * + * Empty filters are left out rather than written as blanks, so an unfiltered + * Tasks screen has a clean URL and two links to the same view compare equal. + */ +export function filterQuery(filters: TaskFilterState): LocationQueryRaw { + const query: LocationQueryRaw = {} + + for (const key of ['project', 'user', 'status', 'search'] as const) { + if (filters[key] !== '') { + query[key] = filters[key] + } + } + + return query +} + +/** Whether anything is filtered, for the "clear" affordance. */ +export function hasFilters(filters: TaskFilterState): boolean { + return filters.project !== '' || filters.user !== '' || filters.status !== '' || filters.search !== '' +} + +/** + * The filters as one comparable string. + * + * A watcher on the object itself would fire on every route change, because the + * screen above rebuilds it from the query each time. Comparing the four values + * fires when they really differ, which is when a list is worth asking for + * again. + */ +export function filterKey(filters: TaskFilterState): string { + return [filters.project, filters.user, filters.status, filters.search].join('|') +} + +export function sameFilters(left: TaskFilterState, right: TaskFilterState): boolean { + return ( + left.project === right.project && + left.user === right.user && + left.status === right.status && + left.search === right.search + ) +} + +/** + * The filters as `GET tasks` takes them. + * + * `project` is overridden by a project page, which fixes the list to its own + * project whatever the address bar says. + */ +export function taskListParams( + filters: TaskFilterState, + overrides: { projectId?: number | null } = {}, +): TaskListParams & SortParams { + const params: TaskListParams & SortParams = {} + const projectId = overrides.projectId ?? idOf(filters.project) + + if (projectId !== null) { + params.project_id = projectId + } + + const assigneeId = idOf(filters.user) + + if (assigneeId !== null) { + params.assignee_id = assigneeId + } + + if (isInvoicedFilter(filters.status)) { + params.invoiced = filters.status === 'invoiced' ? 1 : 0 + } else { + const statusId = idOf(filters.status) + + if (statusId !== null) { + params.task_status_id = statusId + } + } + + if (filters.search !== '') { + params.search = filters.search + } + + return params +} + +/** A filter value as the id it names, or null when it names nothing. */ +export function idOf(value: string): number | null { + const id = Number(value) + + return value !== '' && Number.isInteger(id) && id > 0 ? id : null +} + +function single(value: LocationQuery[string]): string { + const first = Array.isArray(value) ? value[0] : value + + return typeof first === 'string' ? first.trim() : '' +} + +function numeric(value: LocationQuery[string]): string { + const text = single(value) + + return idOf(text) === null ? '' : text +} + +function statusOf(value: LocationQuery[string]): string { + const text = single(value) + + return isInvoicedFilter(text) ? text : numeric(value) +} diff --git a/resources/js/support/http.ts b/resources/js/support/http.ts index 3c9ee01..e907f07 100644 --- a/resources/js/support/http.ts +++ b/resources/js/support/http.ts @@ -16,6 +16,29 @@ export function errorStatus(error: unknown): number | null { return typeof status === 'number' ? status : null } +/** + * The machine-readable `error` key of a failed request. + * + * Every module endpoint answers a refusal as `{ message, error }`, so the + * caller can tell `timer_already_running` from `timer_mismatch` without + * matching on the human sentence, which is translated and may change. + */ +export function errorCode(error: unknown): string | null { + if (typeof error !== 'object' || error === null) { + return null + } + + const data = (error as { response?: { data?: unknown } }).response?.data + + if (typeof data !== 'object' || data === null) { + return null + } + + const code = (data as { error?: unknown }).error + + return typeof code === 'string' && code !== '' ? code : null +} + /** A 409 from `timer/start`: someone else's tab already started the clock. */ export function isConflict(error: unknown): boolean { return errorStatus(error) === 409 diff --git a/resources/js/support/page.ts b/resources/js/support/page.ts index 2c2fcae..35a6456 100644 --- a/resources/js/support/page.ts +++ b/resources/js/support/page.ts @@ -6,6 +6,41 @@ export type NotifyType = 'success' | 'error' | 'warning' | 'info' export type Notify = (type: NotifyType, message: string) => void +/** The module.json slug, which every registered path and route name hangs off. */ +export const MODULE = 'tasks-projects' + +const ROOT = `/admin/modules/${MODULE}` + +/** + * Where each screen lives. + * + * Breadcrumbs and cross-screen links are absolute, because a module page is + * mounted under the host's `admin` route and a relative link would resolve + * against whatever the user happened to arrive from. + */ +export const PATHS = { + tasks: ROOT, + board: `${ROOT}/board`, + week: `${ROOT}/week`, + task: (id: number | string): string => `${ROOT}/tasks/${id}`, + projects: `${ROOT}/projects`, + project: (id: number | string): string => `${ROOT}/projects/${id}`, + reports: `${ROOT}/reports`, + settings: '/admin/settings/modules', + customer: (id: number): string => `/admin/customers/${id}/view`, +} as const + +/** The names the host gives the module's routes, for navigating by name. */ +export const ROUTES = { + tasks: `extension.page.${MODULE}.tasks`, + list: `extension.page.${MODULE}.tasks.list`, + board: `extension.page.${MODULE}.tasks.board`, + week: `extension.page.${MODULE}.tasks.week`, + task: `extension.page.${MODULE}.task`, + projects: `extension.page.${MODULE}.projects`, + project: `extension.page.${MODULE}.project`, +} as const + /** * Hand a page the host services it cannot reach on its own. * @@ -15,10 +50,7 @@ export type Notify = (type: NotifyType, message: string) => void * with `props: true`, and a tab page also receives whatever its parent passes * through ``. */ -export function injectedPage( - extensions: InvoiceShelfExtensionApi, - page: Component, -): Component { +export function injectedPage(extensions: InvoiceShelfExtensionApi, page: Component): Component { return defineComponent({ setup: (_props, { attrs }) => () => h(page, { diff --git a/resources/js/support/time.ts b/resources/js/support/time.ts index 72f73e4..a2894e0 100644 --- a/resources/js/support/time.ts +++ b/resources/js/support/time.ts @@ -156,13 +156,23 @@ export function isToday(date: Date): boolean { /** Seconds elapsed since an instant, never negative and never NaN. */ export function secondsSince(instant: string | null): number { + return secondsBetween(instant, Date.now()) +} + +/** + * The same, measured against a caller-supplied instant. + * + * Every live clock on a screen reads one shared "now", so the rows tick + * together and one timer drives the whole page instead of one per row. + */ +export function secondsBetween(instant: string | null, now: number): number { const start = parseInstant(instant) if (start === null) { return 0 } - return Math.max(0, Math.floor((Date.now() - start.getTime()) / 1000)) + return Math.max(0, Math.floor((now - start.getTime()) / 1000)) } function parseInstant(instant: string | null): Date | null { diff --git a/resources/js/types/settings.ts b/resources/js/types/settings.ts index 1e0b160..e141c9f 100644 --- a/resources/js/types/settings.ts +++ b/resources/js/types/settings.ts @@ -1,9 +1,26 @@ +/** How a stopped entry's minutes are rounded to the increment. */ +export type RoundingDirection = 'nearest' | 'up' | 'down' + /** The module's per-company settings, as the settings endpoint renders them. */ export interface ModuleSettings { /** Minor units per hour. */ default_rate: number rounding_minutes: number + rounding_direction: RoundingDirection week_start: number members_see_all_time: boolean + /** Start the creator's timer as soon as a task is created. */ + auto_start_tasks: boolean + /** Refuse edits to a task whose time is already on an invoice. */ + lock_invoiced_tasks: boolean + /** Keep invoiced tasks off the board. */ + hide_invoiced_on_board: boolean + /** What an invoice line built from a task carries. */ + invoice_project_heading: boolean + invoice_task_description: boolean + invoice_entry_dates: boolean + invoice_entry_times: boolean + invoice_entry_hours: boolean + invoice_entry_descriptions: boolean rounding_increments: number[] } diff --git a/resources/js/types/task.ts b/resources/js/types/task.ts index 59c3d3c..c15ada0 100644 --- a/resources/js/types/task.ts +++ b/resources/js/types/task.ts @@ -4,6 +4,33 @@ export const TASK_PRIORITIES = ['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const export type TaskPriority = (typeof TASK_PRIORITIES)[number] +/** Whether any of a task's billable time has reached an invoice. */ +export type TaskInvoiceState = 'none' | 'uninvoiced' | 'invoiced' + +/** One entry whose clock is running right now, whoever started it. */ +export interface TaskRunningEntry { + entry_id: number + user_id: number + started_at: string | null +} + +/** + * The time summary the API attaches to a task. + * + * Every screen that shows a task shows its time, so the totals ride along with + * the row rather than costing a request each. An older server answers without + * the block, so every read of it is guarded. + */ +export interface TaskTime { + logged_minutes: number + billable_minutes: number + unbilled_minutes: number + /** Minor units, in the currency the entries were logged in. */ + unbilled_amount: number + invoiced: TaskInvoiceState + running: TaskRunningEntry[] +} + export interface Task { id: number company_id: number @@ -28,13 +55,15 @@ export interface Task { creator_id: number | null created_at: string | null updated_at: string | null + /** Absent on a server that predates the time summary. */ + time?: TaskTime } /** * What the create and update endpoints accept. * * `task_status_id` is never null: the update rule takes an integer, and the - * drawer always has a column selected. + * form always has a column selected. */ export interface TaskInput { name: string @@ -58,6 +87,8 @@ export interface TaskListParams { task_status_id?: number customer_id?: number search?: string + /** 1 for tasks already on an invoice, 0 for the ones still waiting. */ + invoiced?: 0 | 1 } /** Where a dragged card landed: its new column and the two tasks around it. */ @@ -66,3 +97,24 @@ export interface TaskMoveInput { before_id: number | null after_id: number | null } + +/** What `POST tasks/bulk` does to the selection. */ +export type TaskBulkAction = 'status' | 'delete' + +export interface TaskBulkInput { + action: TaskBulkAction + ids: number[] + /** Required by the `status` action, ignored by the others. */ + task_status_id?: number +} + +/** A task the bulk endpoint refused, and why. */ +export interface TaskBulkFailure { + id: number + reason: string +} + +export interface TaskBulkResult { + updated: number + failed: TaskBulkFailure[] +} From 6590c907a48c65bbabe4c53697cf555e12f01e84 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 09:46:03 +0200 Subject: [PATCH 02/12] feat(ui): split the task and project catalogues, and name the new screens The slices changed shape, so the bundles follow them: `messages/tasks.ts` holds what the Tasks screen, its three views and the task page render, and what was `messages/board.ts` keeps the project detail alone under its own name. A slice owning one file is what stops two of them editing the same lines, and the host merges every bundle recursively, so nothing about the `tasks_projects` namespace changes. The new strings are the ones the rework needs a word for: the view switcher, the bulk bar and what it reports when the server refuses part of a selection, the invoiced and unbilled badges, the task page's own labels, the time-log columns, and the timer's new answers, including the tooltip that names the task a caller's clock is already on and the "stop and start" that gets out of it. `settings` gains a read-only summary of the new toggles, so the module's settings page can say what the company has chosen without offering a second place to change it. --- resources/js/messages/projects.ts | 76 +++++++++ resources/js/messages/{board.ts => tasks.ts} | 169 ++++++++++--------- resources/js/messages/time.ts | 32 +++- 3 files changed, 200 insertions(+), 77 deletions(-) create mode 100644 resources/js/messages/projects.ts rename resources/js/messages/{board.ts => tasks.ts} (54%) diff --git a/resources/js/messages/projects.ts b/resources/js/messages/projects.ts new file mode 100644 index 0000000..984b436 --- /dev/null +++ b/resources/js/messages/projects.ts @@ -0,0 +1,76 @@ +/** + * Strings for the project index and the project detail tabs. + * + * These live beside the slice that owns them rather than in `messages.ts`, so + * two slices of the module never edit the same catalogue. The host merges + * every bundle recursively, so `tasks_projects.project` here and + * `tasks_projects.tasks` there end up in one namespace. + */ +export const projectMessages = { + en: { + tasks_projects: { + project: { + load_failed: 'Unable to load the project.', + customer: 'Customer', + identifier: 'Identifier', + due_date: 'Due date', + board: 'Board', + tasks: 'Tasks', + invoice_project: 'Invoice project', + invoice_soon: 'Coming with invoicing', + tabs: { + overview: 'Overview', + tasks: 'Tasks', + time: 'Time', + members: 'Members', + }, + overview: { + tasks: 'Tasks', + open_tasks: '{count} open', + closed_tasks: '{count} done', + logged: 'Logged', + billable: 'Billable', + billable_amount: 'Billable value', + unbilled_amount: 'Unbilled', + budget: 'Budget', + budget_used: '{used} of {total}', + budget_over: 'Over budget by {amount}', + no_budget: 'No budget set.', + description: 'Description', + no_description: 'No description yet.', + }, + time: { + title: 'Time log', + add_entry: 'Add entry', + columns: { + date: 'Date', + member: 'Member', + task: 'Task', + minutes: 'Duration', + billable: 'Billable', + amount: 'Amount', + }, + running: 'Running', + removed_member: 'Removed member', + load_failed: 'Unable to load the time entries.', + }, + members: { + title: 'Members', + member: 'Member', + rate: 'Rate / hour', + rate_help: 'Per hour on this project. Leave empty to use the project default.', + attach: 'Add member', + attach_placeholder: 'Choose a member', + attached: '{name} was added to the project.', + detached: '{name} was removed from the project.', + detach_confirm: 'Remove {name} from this project? Their time entries stay.', + empty: 'Nobody is on this project yet.', + all_attached: 'Every company member is already on this project.', + load_failed: 'Unable to load the project members.', + attach_failed: 'Unable to add the member.', + detach_failed: 'Unable to remove the member.', + }, + }, + }, + }, +} diff --git a/resources/js/messages/board.ts b/resources/js/messages/tasks.ts similarity index 54% rename from resources/js/messages/board.ts rename to resources/js/messages/tasks.ts index 22636c9..ddc27c5 100644 --- a/resources/js/messages/board.ts +++ b/resources/js/messages/tasks.ts @@ -1,12 +1,12 @@ /** - * Strings for the board, the task screens and the project detail. + * Every string the Tasks screen, its three views and the task page render. * * These live beside the slice that owns them rather than in `messages.ts`, so * two slices of the module never edit the same catalogue. The host merges - * every bundle recursively, so `tasks_projects.board` here and + * every bundle recursively, so `tasks_projects.tasks` here and * `tasks_projects.projects` there end up in one namespace. */ -export const boardMessages = { +export const taskMessages = { en: { tasks_projects: { board: { @@ -15,18 +15,15 @@ export const boardMessages = { move_failed: 'Unable to move the task.', moved: '{name} moved to {status}.', empty_column: 'Nothing here yet', - filters: { - project: 'Project', - all_projects: 'All projects', - assignee: 'Assignee', - all_assignees: 'Everyone', - }, + hidden_invoiced: 'Invoiced tasks are hidden on the board.', }, tasks: { title: 'Tasks', all_tasks: 'All tasks', new_task: 'New task', edit_task: 'Edit task', + all_fields: 'All fields', + fewer_fields: 'Fewer fields', search_placeholder: 'Search by name or number', empty_title: 'No tasks yet', empty_description: 'Add a task to put work on the board.', @@ -34,6 +31,28 @@ export const boardMessages = { billable: 'Billable', overdue: 'Overdue', none: 'None', + no_project: 'No project', + internal: 'Internal', + invoiced: 'Invoiced', + uninvoiced: 'Unbilled', + invoice_task: 'Invoice task', + invoice_soon: 'Coming with invoicing', + locked: 'This task is on an invoice and cannot be changed.', + views: { + list: 'List', + board: 'Board', + week: 'Week', + }, + filters: { + project: 'Project', + all_projects: 'All projects', + member: 'Member', + all_members: 'Everyone', + status: 'Status', + all_statuses: 'Any status', + invoicing: 'Invoicing', + search: 'Search', + }, columns: { number: 'No.', name: 'Name', @@ -42,7 +61,72 @@ export const boardMessages = { assignee: 'Assignee', priority: 'Priority', due_date: 'Due date', + logged: 'Logged', + unbilled: 'Unbilled', + invoiced: 'Invoicing', + timer: 'Timer', }, + bulk: { + selected: '{count} selected', + select_page: 'Select this page', + clear: 'Clear', + change_status: 'Move to', + delete: 'Delete', + invoice: 'Invoice', + delete_confirm: 'Delete {count} tasks? Their time entries go with them.', + applied: '{count} tasks were updated.', + deleted: '{count} tasks were deleted.', + partial: '{count} tasks were updated, {failed} were refused.', + nothing: 'No task was changed.', + failed: 'Unable to apply the change.', + }, + detail: { + estimate: 'Estimate', + logged: 'Logged', + unbilled: 'Unbilled', + no_estimate: 'No estimate', + project: 'Project', + customer: 'Customer', + status: 'Status', + assignee: 'Assignee', + priority: 'Priority', + due_date: 'Due date', + description: 'Description', + no_description: 'No description yet.', + status_saved: 'The status was changed to {name}.', + status_failed: 'Unable to change the status.', + not_found: 'That task could not be loaded.', + }, + time_log: { + title: 'Time log', + add_item: 'Add item', + add_disabled: 'Stop the running timer to log an entry by hand.', + running: 'Running', + empty: 'No time logged against this task yet.', + load_failed: 'Unable to load the time log.', + stamped: 'Invoiced', + stamped_delete: 'Invoiced time belongs to its invoice and cannot be deleted.', + columns: { + start_date: 'Start date', + start_time: 'Start', + end_date: 'End date', + end_time: 'End', + duration: 'Duration', + description: 'Description', + billable: 'Billable', + member: 'Member', + }, + }, + created: '{name} was created.', + updated: '{name} was updated.', + deleted: '{name} was deleted.', + delete_confirm: 'Delete {name}? Its time entries go with it.', + name_required: 'Enter a task name.', + load_failed: 'Unable to load the tasks.', + save_failed: 'Unable to save the task.', + delete_failed: 'Unable to delete the task.', + projects_failed: 'Unable to load the projects.', + members_failed: 'Unable to load the members.', fields: { name: 'Name', description: 'Description', @@ -68,78 +152,11 @@ export const boardMessages = { high: 'High', urgent: 'Urgent', }, - created: '{name} was created.', - updated: '{name} was updated.', - deleted: '{name} was deleted.', - delete_confirm: 'Delete {name}? Its time entries go with it.', - name_required: 'Enter a task name.', - load_failed: 'Unable to load the tasks.', - save_failed: 'Unable to save the task.', - delete_failed: 'Unable to delete the task.', - projects_failed: 'Unable to load the projects.', - members_failed: 'Unable to load the members.', }, task_statuses: { load_failed: 'Unable to load the task statuses.', none: 'No board columns yet.', }, - project: { - load_failed: 'Unable to load the project.', - customer: 'Customer', - identifier: 'Identifier', - due_date: 'Due date', - board: 'Board', - tabs: { - overview: 'Overview', - tasks: 'Tasks', - time: 'Time', - members: 'Members', - }, - overview: { - tasks: 'Tasks', - open_tasks: '{count} open', - closed_tasks: '{count} done', - logged: 'Logged', - billable: 'Billable', - billable_amount: 'Billable value', - unbilled_amount: 'Unbilled', - budget: 'Budget', - budget_used: '{used} of {total}', - budget_over: 'Over budget by {amount}', - no_budget: 'No budget set.', - description: 'Description', - no_description: 'No description yet.', - }, - time: { - columns: { - date: 'Date', - member: 'Member', - task: 'Task', - minutes: 'Duration', - billable: 'Billable', - amount: 'Amount', - }, - running: 'Running', - removed_member: 'Removed member', - load_failed: 'Unable to load the time entries.', - }, - members: { - title: 'Members', - member: 'Member', - rate: 'Rate / hour', - rate_help: 'Per hour on this project. Leave empty to use the project default.', - attach: 'Add member', - attach_placeholder: 'Choose a member', - attached: '{name} was added to the project.', - detached: '{name} was removed from the project.', - detach_confirm: 'Remove {name} from this project? Their time entries stay.', - empty: 'Nobody is on this project yet.', - all_attached: 'Every company member is already on this project.', - load_failed: 'Unable to load the project members.', - attach_failed: 'Unable to add the member.', - detach_failed: 'Unable to remove the member.', - }, - }, }, }, } diff --git a/resources/js/messages/time.ts b/resources/js/messages/time.ts index 2a65515..c901f3c 100644 --- a/resources/js/messages/time.ts +++ b/resources/js/messages/time.ts @@ -90,7 +90,14 @@ export const timeMessages = { stop: 'Stop', discard: 'Discard', close: 'Close', - open_timesheet: 'Open my time', + open_timesheet: 'Open my week', + open_task: 'Open the running task', + start_on: 'Start the timer on {name}', + stop_on: 'Stop the timer on {name}', + busy_elsewhere: 'Your timer is running on {name}.', + stop_and_start: 'Stop and start', + running_by: '{name} has been running since {time}.', + mismatch: 'Your timer is no longer on this task. It has been reloaded.', elapsed: 'Elapsed', search_tasks: 'Search tasks', no_tasks: 'No tasks match that search.', @@ -110,6 +117,29 @@ export const timeMessages = { 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', + behaviour_title: 'Task behaviour', + behaviour_description: + 'What happens when a task is created, invoiced or shown on the board. Change these in the module settings form.', + rounding_direction: 'Rounding', + rounding_direction_nearest: 'To the nearest increment', + rounding_direction_up: 'Up to the increment', + rounding_direction_down: 'Down to the increment', + rounding_increment: 'Increment', + rounding_increment_value: '{count} minutes', + auto_start_tasks: 'Start the timer on a new task', + lock_invoiced_tasks: 'Lock invoiced tasks', + hide_invoiced_on_board: 'Hide invoiced tasks on the board', + invoice_title: 'Invoice lines', + invoice_description: + 'What an invoice line built from a task carries. Change these in the module settings form.', + invoice_project_heading: 'Project heading', + invoice_task_description: 'Task description', + invoice_entry_dates: 'Entry dates', + invoice_entry_times: 'Entry times', + invoice_entry_hours: 'Entry hours', + invoice_entry_descriptions: 'Entry descriptions', + on: 'On', + off: 'Off', 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.', From a5602d8ebc4f59487797e9f598141d35f7511d25 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 09:46:16 +0200 Subject: [PATCH 03/12] feat(ui): add the run control, the filters, the bulk bar and the task card `TaskRunControl` is the one place start and stop are spelled out, so a row, a card and the task page all behave the same. One timer per user is a hard invariant, so a task that is not the one running shows a disabled play with a tooltip naming what is running and a "stop and start" beside it, which is the only honest way to offer the thing people actually want. Another member's clock renders as their initials and a running time with no button: their time is theirs to stop. `TaskFilters` puts project, member, status and search in one row and hands them up as a whole. "Uninvoiced" and "Invoiced" sit in the status picker rather than in a control of their own: it is a question people ask of a task list, it cuts across the columns rather than being one of them, and a second picker is one nobody would find. `ViewSwitcher` pushes `{ name, query }` so a view change carries the filters rather than resetting them. `BulkActionBar` offers a status change and a delete. Start, stop and invoice are not here: the first two would break the one-timer invariant, and invoicing arrives in its own slice, so its button is rendered disabled rather than left out, because a menu that changes shape under people is worse than one with a greyed row in it. `TaskDrawer` becomes `TaskFormModal` with a compact mode. Creating a task should cost a name and a column; the description, the estimate and the rate override are things people come back to fill in, so they sit behind "All fields". A refusal carrying `task_locked` says so in words instead of failing generically, because the invoice, not a bug, is in the way. `TimeEntryModal` gains `lockTask` and a preset task: opened from a task's own log, moving the entry to another task is a way to lose it, and the log it would move to is one click away. --- resources/js/components/BulkActionBar.vue | 93 +++++++++ resources/js/components/InvoicedBadge.vue | 46 +++++ resources/js/components/TaskCard.vue | 153 +++++++++++++++ resources/js/components/TaskFilters.vue | 158 ++++++++++++++++ .../{TaskDrawer.vue => TaskFormModal.vue} | 51 ++++- resources/js/components/TaskRunControl.vue | 179 ++++++++++++++++++ resources/js/components/TimeEntryModal.vue | 17 +- resources/js/components/ViewSwitcher.vue | 85 +++++++++ 8 files changed, 778 insertions(+), 4 deletions(-) create mode 100644 resources/js/components/BulkActionBar.vue create mode 100644 resources/js/components/InvoicedBadge.vue create mode 100644 resources/js/components/TaskCard.vue create mode 100644 resources/js/components/TaskFilters.vue rename resources/js/components/{TaskDrawer.vue => TaskFormModal.vue} (86%) create mode 100644 resources/js/components/TaskRunControl.vue create mode 100644 resources/js/components/ViewSwitcher.vue diff --git a/resources/js/components/BulkActionBar.vue b/resources/js/components/BulkActionBar.vue new file mode 100644 index 0000000..8151d1c --- /dev/null +++ b/resources/js/components/BulkActionBar.vue @@ -0,0 +1,93 @@ + + + diff --git a/resources/js/components/InvoicedBadge.vue b/resources/js/components/InvoicedBadge.vue new file mode 100644 index 0000000..9aff817 --- /dev/null +++ b/resources/js/components/InvoicedBadge.vue @@ -0,0 +1,46 @@ + + + diff --git a/resources/js/components/TaskCard.vue b/resources/js/components/TaskCard.vue new file mode 100644 index 0000000..ac5cbd2 --- /dev/null +++ b/resources/js/components/TaskCard.vue @@ -0,0 +1,153 @@ + + + diff --git a/resources/js/components/TaskFilters.vue b/resources/js/components/TaskFilters.vue new file mode 100644 index 0000000..d2939a5 --- /dev/null +++ b/resources/js/components/TaskFilters.vue @@ -0,0 +1,158 @@ + + + diff --git a/resources/js/components/TaskDrawer.vue b/resources/js/components/TaskFormModal.vue similarity index 86% rename from resources/js/components/TaskDrawer.vue rename to resources/js/components/TaskFormModal.vue index 8519459..eff4a59 100644 --- a/resources/js/components/TaskDrawer.vue +++ b/resources/js/components/TaskFormModal.vue @@ -3,6 +3,7 @@ import { computed, reactive, ref, watch } from 'vue' import type { AxiosInstance } from 'axios' import { createTask, deleteTask, updateTask } from '@/api/board' import { errorMessage, fieldErrors } from '@/support/errors' +import { errorCode } from '@/support/http' import { hoursToMinutes, majorToMinor, @@ -37,6 +38,16 @@ const props = defineProps<{ defaults?: TaskDefaults /** A project page fixes the project, so the picker is hidden. */ lockProject?: boolean + /** + * Show the six fields a task is usually created with, and put the rest + * behind a disclosure. + * + * Creating a task should cost a name and a column; a description, an + * estimate and a rate override are things people come back to fill in, and + * asking for them up front is what made the old drawer read as a form to be + * completed rather than a box to be typed into. + */ + compact?: boolean }>() const emit = defineEmits<{ @@ -66,6 +77,10 @@ const customerId = ref(null) const errors = ref>({}) const saving = ref(false) const removing = ref(false) +const expanded = ref(false) + +/** Whether the fields behind the compact mode's disclosure are on show. */ +const showAllFields = computed(() => !props.compact || expanded.value) /** * The picker binds an option, the payload wants an id. @@ -140,6 +155,7 @@ function reset(): void { : null customerId.value = task?.customer_id ?? null errors.value = {} + expanded.value = false } function onDueDate(value: string | Date): void { @@ -201,12 +217,25 @@ async function save(): Promise { emit('saved', task) } catch (error: unknown) { errors.value = fieldErrors(error) - props.notify('error', errorMessage(error, t('tasks_projects.tasks.save_failed'))) + props.notify('error', errorMessage(error, fallbackFor(error, 'save_failed'))) } finally { saving.value = false } } +/** + * What to say when the server did not. + * + * A locked task is refused for a reason worth naming rather than as a generic + * failure, so the caller understands that the invoice, not a bug, is in the + * way. + */ +function fallbackFor(error: unknown, key: 'save_failed' | 'delete_failed'): string { + return errorCode(error) === 'task_locked' + ? t('tasks_projects.tasks.locked') + : t(`tasks_projects.tasks.${key}`) +} + async function remove(): Promise { const task = props.task @@ -224,7 +253,7 @@ async function remove(): Promise { await deleteTask(props.client, task.id) emit('deleted', task) } catch (error: unknown) { - props.notify('error', errorMessage(error, t('tasks_projects.tasks.delete_failed'))) + props.notify('error', errorMessage(error, fallbackFor(error, 'delete_failed'))) } finally { removing.value = false } @@ -300,6 +329,7 @@ async function remove(): Promise { @@ -319,6 +349,7 @@ async function remove(): Promise { @@ -332,6 +363,7 @@ async function remove(): Promise { { @@ -360,6 +393,20 @@ async function remove(): Promise { :invalid="Boolean(errors.description)" /> + +
diff --git a/resources/js/components/TaskRunControl.vue b/resources/js/components/TaskRunControl.vue new file mode 100644 index 0000000..8638984 --- /dev/null +++ b/resources/js/components/TaskRunControl.vue @@ -0,0 +1,179 @@ + + + diff --git a/resources/js/components/TimeEntryModal.vue b/resources/js/components/TimeEntryModal.vue index e702966..73a7f3c 100644 --- a/resources/js/components/TimeEntryModal.vue +++ b/resources/js/components/TimeEntryModal.vue @@ -35,6 +35,16 @@ const props = defineProps<{ entry: TimeEntry | null /** The day a new entry lands on, as `Y-m-d`. */ defaultDate?: string + /** The task a new entry is logged against, when the caller already knows it. */ + defaultTask?: TaskSummary | null + /** + * Opened from a task's own time log, where the task is not a choice. + * + * The picker becomes a label: moving an entry to another task from inside + * that task's log is a way to lose it, and the log it would move to is one + * click away. + */ + lockTask?: boolean }>() const emit = defineEmits<{ @@ -93,13 +103,13 @@ function reset(): void { const entry = props.entry errors.value = {} - task.value = null + task.value = entry === null ? (props.defaultTask ?? null) : null form.date = entry ? localDateOf(entry.started_at) : (props.defaultDate ?? formatLocalDate(new Date())) form.duration = entry ? formatDuration(entry.duration_minutes) : '' form.start = entry?.started_at ? localTimeOf(entry.started_at) : DEFAULT_START_TIME form.end = entry?.ended_at ? localTimeOf(entry.ended_at) : '' form.description = entry?.description ?? '' - form.billable = entry ? entry.billable : true + form.billable = entry ? entry.billable : (task.value?.billable ?? true) form.mode = entry !== null && matchesRange(entry) ? 'range' : 'duration' if (form.date === '') { @@ -301,7 +311,10 @@ async function remove(): Promise { :error="errors.task_id" required > + + +import { computed } from 'vue' +import type { LocationQueryRaw } from 'vue-router' +import { useTranslate } from '@/support/i18n' +import { ROUTES } from '@/support/page' + +interface View { + id: string + name: string + label: string + icon: string +} + +const props = defineProps<{ + /** The route name the Tasks screen is currently showing. */ + active: string + /** The filters, so a view change keeps them. */ + query: LocationQueryRaw +}>() + +const emit = defineEmits<{ + (event: 'select', to: { name: string; query: LocationQueryRaw }): void +}>() + +const t = useTranslate() + +const views = computed(() => [ + { + id: 'list', + name: ROUTES.list, + label: t('tasks_projects.tasks.views.list'), + icon: 'ListBulletIcon', + }, + { + id: 'board', + name: ROUTES.board, + label: t('tasks_projects.tasks.views.board'), + icon: 'ViewColumnsIcon', + }, + { + id: 'week', + name: ROUTES.week, + label: t('tasks_projects.tasks.views.week'), + icon: 'CalendarDaysIcon', + }, +]) + +/** + * The index child is what a link to the module root resolves to, but a visit + * to the parent route itself is the same screen, so both light the List tab. + */ +function isActive(view: View): boolean { + return props.active === view.name || (view.id === 'list' && props.active === ROUTES.tasks) +} + +function select(view: View): void { + if (!isActive(view)) { + emit('select', { name: view.name, query: props.query }) + } +} + + + From d1afec3afc45c977bd0c2af9d3a8a7b2f03e9cfb Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Tue, 15 Sep 2026 09:46:29 +0200 Subject: [PATCH 04/12] feat(ui): make Tasks the module root, with List, Board and Week over one filter The three ways of reading the work were three destinations before: a Tasks page with no inbound links, a Board behind a button, and a Time page nothing pointed at. They are now three children of one screen. The header, the filters and the pickers are loaded once by `TasksPage`, so a view switch costs one request for the rows and none for the pickers, and the filters live in the query string, so List, Board and Week are three readings of the same question rather than three places to ask it again. The list gains what the screen was missing: selection and a bulk bar, a name that leads to the task rather than opening a drawer over it, time logged, money still unbilled, an invoiced badge and a run control on every row. Sorting keeps only the columns the endpoint can order by. The board keeps its drag and drop and its per-column "+", which now presets the column and the filtered project. Two rules hide cards: the company's `hide_invoiced_on_board` setting and the invoiced status filter. A drag reports positions in the rendered list, so every index is translated back through the column's own array before anything moves, otherwise a hidden card would make the drop land a row out. The week view is the old timesheet under its new home, honouring the shared member and project filters. `AllTimeTable` loses its own member and project pickers for the same reason: two pairs of controls over one list can disagree, and the pair on the header is the one every view shares. --- resources/js/components/AllTimeTable.vue | 59 +-- resources/js/components/TaskList.vue | 394 ++++++++++--------- resources/js/components/WeekTimesheet.vue | 22 +- resources/js/pages/TasksPage.vue | 182 ++++++++- resources/js/pages/tasks/TasksBoardView.vue | 401 ++++++++++++++++++++ resources/js/pages/tasks/TasksListView.vue | 42 ++ resources/js/pages/tasks/TasksWeekView.vue | 201 ++++++++++ 7 files changed, 1057 insertions(+), 244 deletions(-) create mode 100644 resources/js/pages/tasks/TasksBoardView.vue create mode 100644 resources/js/pages/tasks/TasksListView.vue create mode 100644 resources/js/pages/tasks/TasksWeekView.vue diff --git a/resources/js/components/AllTimeTable.vue b/resources/js/components/AllTimeTable.vue index 1c1b3ef..cd0e7a2 100644 --- a/resources/js/components/AllTimeTable.vue +++ b/resources/js/components/AllTimeTable.vue @@ -8,7 +8,6 @@ import { formatDate, toDateString } from '@/support/format' import { useTranslate } from '@/support/i18n' import { formatDuration, localDateOf } from '@/support/time' import type { CompanyMember } from '@/types/member' -import type { Project } from '@/types/project' import type { TimeEntry, TimeEntryListParams } from '@/types/time-entry' type NotifyType = 'success' | 'error' | 'warning' | 'info' @@ -31,7 +30,15 @@ const props = defineProps<{ client: AxiosInstance notify: (type: NotifyType, message: string) => void members: CompanyMember[] - projects: Project[] + /** + * Who and what the screen above is filtered to. + * + * The member and project pickers live on the Tasks header now, shared by + * every view, so the table follows them rather than offering a second pair + * that could disagree with the first. + */ + memberId: number | null + projectId: number | null /** Bumped by the page whenever an entry was saved elsewhere. */ reloadToken: number }>() @@ -46,49 +53,21 @@ const t = useTranslate() const tableRef = ref<{ refresh: (preservePage?: boolean) => void } | null>(null) const filters = reactive<{ - memberId: number | null - projectId: number | null from: string to: string billing: BillingFilter }>({ - memberId: null, - projectId: null, from: '', to: '', billing: 'ALL', }) -const memberOptions = computed(() => [ - { id: 0, label: t('tasks_projects.time.filters.any_member') }, - ...props.members.map((member) => ({ id: member.id, label: member.name })), -]) - -const projectOptions = computed(() => [ - { id: 0, label: t('tasks_projects.time.filters.any_project') }, - ...props.projects.map((project) => ({ id: project.id, label: project.name })), -]) - const billingOptions = computed(() => [ { id: 'ALL', label: t('tasks_projects.time.filters.all') }, { id: 'BILLED', label: t('tasks_projects.time.billed') }, { id: 'UNBILLED', label: t('tasks_projects.time.unbilled') }, ]) -const memberOption = computed