From 56d30220664dbf1a205f307b15c4a3f61b140453 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:45:01 +0200 Subject: [PATCH 1/4] feat(ui): add the task, board and project detail API client The typed client wraps the task, task status, board, project member and time entry endpoints over the axios instance the host injects, alongside the host contact lookup the project header needs for a customer name. The base path moves out of the projects client so both files build their URLs from one constant. Money stays in integer minor units and durations in minutes on the wire; support/format gains the duration, initials and overdue helpers the board and the project totals render with. sortablejs is a runtime dependency and is bundled into dist/, which is how an npm dependency reaches an install. --- package.json | 4 + pnpm-lock.yaml | 17 ++++ resources/js/api.ts | 3 +- resources/js/api/board.ts | 143 +++++++++++++++++++++++++++ resources/js/support/format.ts | 39 ++++++++ resources/js/types/board.ts | 22 +++++ resources/js/types/project-member.ts | 21 ++++ resources/js/types/task-status.ts | 15 +++ resources/js/types/task.ts | 68 +++++++++++++ resources/js/types/time-entry.ts | 36 +++++++ 10 files changed, 367 insertions(+), 1 deletion(-) create mode 100644 resources/js/api/board.ts create mode 100644 resources/js/types/board.ts create mode 100644 resources/js/types/project-member.ts create mode 100644 resources/js/types/task-status.ts create mode 100644 resources/js/types/task.ts create mode 100644 resources/js/types/time-entry.ts diff --git a/package.json b/package.json index 10e67d9..1cde4fc 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,12 @@ "build": "vite build", "lint": "eslint resources/js --ext .ts,.vue --max-warnings 0" }, + "dependencies": { + "sortablejs": "^1.15.7" + }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@types/sortablejs": "^1.15.9", "@vitejs/plugin-vue": "^6.0.5", "axios": "^1.13.6", "eslint": "^9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d8740d..868f3c2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,10 +7,17 @@ settings: importers: .: + dependencies: + sortablejs: + specifier: ^1.15.7 + version: 1.15.7 devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 version: 4.3.3(vite@8.3.0(jiti@2.7.0)) + '@types/sortablejs': + specifier: ^1.15.9 + version: 1.15.9 '@vitejs/plugin-vue': specifier: ^6.0.5 version: 6.0.8(vite@8.3.0(jiti@2.7.0))(vue@3.5.42(typescript@6.0.3)) @@ -340,6 +347,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/sortablejs@1.15.9': + resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==} + '@typescript-eslint/eslint-plugin@8.70.0': resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1057,6 +1067,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + sortablejs@1.15.7: + resolution: {integrity: sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1406,6 +1419,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/sortablejs@1.15.9': {} + '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.5(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2122,6 +2137,8 @@ snapshots: shebang-regex@3.0.0: {} + sortablejs@1.15.7: {} + source-map-js@1.2.1: {} strip-json-comments@3.1.1: {} diff --git a/resources/js/api.ts b/resources/js/api.ts index 1a00a90..ff103b0 100644 --- a/resources/js/api.ts +++ b/resources/js/api.ts @@ -4,7 +4,8 @@ import type { CompanyMember } from '@/types/member' import type { ModuleSettings } from '@/types/settings' import type { Project, ProjectInput, ProjectListParams } from '@/types/project' -const BASE = '/api/v1/tasks-projects' +/** Every module path hangs off the slug prefix, so a core route can never collide. */ +export const BASE = '/api/v1/tasks-projects' /** Every endpoint the module owns. */ export const TASKS_PROJECTS_API = { diff --git a/resources/js/api/board.ts b/resources/js/api/board.ts new file mode 100644 index 0000000..97bf852 --- /dev/null +++ b/resources/js/api/board.ts @@ -0,0 +1,143 @@ +import type { AxiosInstance } from 'axios' +import { BASE, HOST_API, TASKS_PROJECTS_API } from '@/api' +import type { Customer, 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 { TaskStatus } from '@/types/task-status' +import type { TimeEntry, TimeEntryListParams } from '@/types/time-entry' + +/** The endpoints the board, the task lists and the project detail read. */ +export const BOARD_API = { + board: `${BASE}/board`, + tasks: `${BASE}/tasks`, + task: (id: number): string => `${BASE}/tasks/${id}`, + moveTask: (id: number): string => `${BASE}/tasks/${id}/move`, + taskStatuses: `${BASE}/task-statuses`, + timeEntries: `${BASE}/time-entries`, + projectMembers: (projectId: number): string => `${BASE}/projects/${projectId}/members`, + projectMember: (projectId: number, userId: number): string => + `${BASE}/projects/${projectId}/members/${userId}`, +} as const + +/** Every column of the company with its tasks, in one request. */ +export async function fetchBoard( + client: AxiosInstance, + params: BoardParams, +): Promise { + const { data } = await client.get>(BOARD_API.board, { params }) + + return data.data +} + +/** The board columns on their own, for the drawer's status picker. */ +export async function listTaskStatuses(client: AxiosInstance): Promise { + const { data } = await client.get>(BOARD_API.taskStatuses) + + return data.data +} + +export async function listTasks( + client: AxiosInstance, + params: TaskListParams, +): Promise> { + const { data } = await client.get>(BOARD_API.tasks, { params }) + + return data +} + +export async function createTask(client: AxiosInstance, input: TaskInput): Promise { + const { data } = await client.post>(BOARD_API.tasks, input) + + return data.data +} + +export async function updateTask( + client: AxiosInstance, + id: number, + input: TaskInput, +): Promise { + const { data } = await client.put>(BOARD_API.task(id), input) + + return data.data +} + +export async function deleteTask(client: AxiosInstance, id: number): Promise { + await client.delete(BOARD_API.task(id)) +} + +/** + * Drop a task between two neighbours of a column. + * + * The server owns the ordering: it returns the task with the `board_position` + * it settled on, which the board applies rather than guessing one itself. + */ +export async function moveTask( + client: AxiosInstance, + id: number, + input: TaskMoveInput, +): Promise { + const { data } = await client.post>(BOARD_API.moveTask(id), input) + + return data.data +} + +/** One project with the totals only the detail endpoint carries. */ +export async function fetchProject(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(TASKS_PROJECTS_API.project(id)) + + return data.data +} + +export async function listProjectMembers( + client: AxiosInstance, + projectId: number, +): Promise { + const { data } = await client.get>(BOARD_API.projectMembers(projectId)) + + return data.data +} + +export async function attachProjectMember( + client: AxiosInstance, + projectId: number, + input: ProjectMemberInput, +): Promise { + const { data } = await client.post>( + BOARD_API.projectMembers(projectId), + input, + ) + + return data.data +} + +export async function detachProjectMember( + client: AxiosInstance, + projectId: number, + userId: number, +): Promise { + await client.delete(BOARD_API.projectMember(projectId, userId)) +} + +/** The time logged against one project, for the read-only detail tab. */ +export async function listProjectTime( + client: AxiosInstance, + params: TimeEntryListParams, +): Promise> { + const { data } = await client.get>(BOARD_API.timeEntries, { params }) + + return data +} + +/** + * One host contact, for the name the project header shows. + * + * A project detail only ever knows the customer id, and the contact may have + * been deleted since, so the caller falls back to `#id` on failure. + */ +export async function fetchCustomer(client: AxiosInstance, id: number): Promise { + const { data } = await client.get>(`${HOST_API.customers}/${id}`) + + return data.data +} diff --git a/resources/js/support/format.ts b/resources/js/support/format.ts index d5d4e27..76bd4ab 100644 --- a/resources/js/support/format.ts +++ b/resources/js/support/format.ts @@ -61,3 +61,42 @@ export function toDateString(value: string | Date): string { return `${value.getFullYear()}-${month}-${day}` } + +/** Minutes as the hours and minutes a timesheet reads back, such as "2h 30m". */ +export function formatMinutes(minutes: number | null): string { + const total = Math.max(0, Math.round(minutes ?? 0)) + const hours = Math.floor(total / 60) + const rest = total % 60 + + if (hours === 0) { + return `${rest}m` + } + + return rest === 0 ? `${hours}h` : `${hours}h ${rest}m` +} + +/** The one or two letters an avatar chip shows for a person. */ +export function initials(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean) + + if (parts.length === 0) { + return '?' + } + + const first = parts[0].charAt(0) + const last = parts.length > 1 ? parts[parts.length - 1].charAt(0) : '' + + return (first + last).toUpperCase() +} + +/** Whether a `Y-m-d` date has already passed, compared in the viewer's day. */ +export function isOverdue(value: string | null): boolean { + if (!value) { + return false + } + + const today = new Date() + const stamp = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}` + + return value.slice(0, 10) < stamp +} diff --git a/resources/js/types/board.ts b/resources/js/types/board.ts new file mode 100644 index 0000000..c47cf9e --- /dev/null +++ b/resources/js/types/board.ts @@ -0,0 +1,22 @@ +import type { Task } from '@/types/task' +import type { TaskStatus } from '@/types/task-status' + +/** One column of `GET board`: a status with its tasks in board order. */ +export interface BoardColumn { + status: TaskStatus + tasks: Task[] +} + +export interface BoardParams { + project_id?: number + assignee_id?: number +} + +/** + * What the host select inputs bind. They hand back the whole option object + * rather than an id, so every picker works on this shape. + */ +export interface SelectOption { + id: number + label: string +} diff --git a/resources/js/types/project-member.ts b/resources/js/types/project-member.ts new file mode 100644 index 0000000..7feaf59 --- /dev/null +++ b/resources/js/types/project-member.ts @@ -0,0 +1,21 @@ +/** + * A user attached to a project, as `ProjectMemberResource` renders it. + * + * `user_id` points at a host user without a foreign key, so a member who has + * left the company still renders here. + */ +export interface ProjectMember { + id: number + company_id: number + project_id: number + user_id: number + /** Minor units per hour for this member on this project. */ + rate: number | null + created_at: string | null + updated_at: string | null +} + +export interface ProjectMemberInput { + user_id: number + rate: number | null +} diff --git a/resources/js/types/task-status.ts b/resources/js/types/task-status.ts new file mode 100644 index 0000000..bdc7b55 --- /dev/null +++ b/resources/js/types/task-status.ts @@ -0,0 +1,15 @@ +/** One board column, as `TaskStatusResource` renders it. */ +export interface TaskStatus { + id: number + company_id: number + name: string + colour: string | null + /** Column order on the board. */ + position: number + /** Where a task lands when none is named. */ + is_default: boolean + /** Counts as done, and stamps the task's `closed_at`. */ + is_closed: boolean + created_at: string | null + updated_at: string | null +} diff --git a/resources/js/types/task.ts b/resources/js/types/task.ts new file mode 100644 index 0000000..59c3d3c --- /dev/null +++ b/resources/js/types/task.ts @@ -0,0 +1,68 @@ +/** A task as `TaskResource` renders it. Money is integer minor units. */ + +export const TASK_PRIORITIES = ['LOW', 'NORMAL', 'HIGH', 'URGENT'] as const + +export type TaskPriority = (typeof TASK_PRIORITIES)[number] + +export interface Task { + id: number + company_id: number + project_id: number | null + /** Denormalised from the project, or set directly on a standalone task. */ + customer_id: number | null + task_status_id: number + /** A per-company sequence, for referring to a task in an email. */ + number: number + name: string + description: string | null + assignee_id: number | null + priority: TaskPriority | null + due_date: string | null + estimated_minutes: number | null + billable: boolean + /** Minor units per hour, overriding the member and project rates. */ + rate: number | null + /** Fractional board order, kept as a string so no float rewrites it. */ + board_position: string + closed_at: string | null + creator_id: number | null + created_at: string | null + updated_at: string | null +} + +/** + * 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. + */ +export interface TaskInput { + name: string + task_status_id: number + project_id: number | null + customer_id: number | null + description: string | null + assignee_id: number | null + priority: TaskPriority | null + due_date: string | null + estimated_minutes: number | null + billable: boolean + rate: number | null +} + +export interface TaskListParams { + page?: number + limit?: number + project_id?: number + assignee_id?: number + task_status_id?: number + customer_id?: number + search?: string +} + +/** Where a dragged card landed: its new column and the two tasks around it. */ +export interface TaskMoveInput { + task_status_id: number + before_id: number | null + after_id: number | null +} diff --git a/resources/js/types/time-entry.ts b/resources/js/types/time-entry.ts new file mode 100644 index 0000000..550f2d8 --- /dev/null +++ b/resources/js/types/time-entry.ts @@ -0,0 +1,36 @@ +/** + * Logged time, as `TimeEntryResource` renders it. Durations are minutes, + * `rate` is minor units per hour and `amount` is the frozen money on the + * entry, also in minor units. + */ +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 +} + +export interface TimeEntryListParams { + page?: number + limit?: number + project_id?: number + task_id?: number + user_id?: number + from?: string + to?: string +} From f3c3f824fe7e6150b4ab74b9ca84dd9de09ce547 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:45:09 +0200 Subject: [PATCH 2/4] feat(ui): add the task drawer and the task list The drawer is the one editor for a task, shared by the board and every list: name, project, status, assignee, priority, due date, estimate, billable flag and a rate override, with hours converted to minutes and major units to minor units on the way out. A 422 renders under its field. The customer is read-only, because a task with a project takes the project's. The project id is the source of truth behind the project picker, so a page that fixes the project and hides the picker still saves the task under it, and editing a task whose project is not in the loaded list never clears it. The list is a server-side BaseTable over the tasks endpoint with search, status and assignee filters, and it tells its parent when a write lands so a project's totals can catch up. --- resources/js/components/TaskDrawer.vue | 389 +++++++++++++++++++++++ resources/js/components/TaskList.vue | 414 +++++++++++++++++++++++++ 2 files changed, 803 insertions(+) create mode 100644 resources/js/components/TaskDrawer.vue create mode 100644 resources/js/components/TaskList.vue diff --git a/resources/js/components/TaskDrawer.vue b/resources/js/components/TaskDrawer.vue new file mode 100644 index 0000000..8519459 --- /dev/null +++ b/resources/js/components/TaskDrawer.vue @@ -0,0 +1,389 @@ + + + diff --git a/resources/js/components/TaskList.vue b/resources/js/components/TaskList.vue new file mode 100644 index 0000000..28d3618 --- /dev/null +++ b/resources/js/components/TaskList.vue @@ -0,0 +1,414 @@ + + + From eed3b9760a1d0a634b0bc12d3fd3901fb40e96b1 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 23:45:20 +0200 Subject: [PATCH 3/4] feat(ui): add the Kanban board, the task page and the project detail Three pages register through the host page contract, from their own registration file so a later slice of the module never edits the same lines. Their strings live in their own message bundle, which the host merges into the module's namespace recursively. The board reads every column in one request and drags with sortablejs. A drop is undone in the DOM first, so the model stays the only writer, then the neighbours in the target column become the before and after ids the move endpoint orders by; the returned board position is applied and a failed move puts both columns back. Each column opens the drawer preset to itself. The project detail carries the header, the actions and four tabs rendered through router-view: totals and a budget bar, the task list, a read-only time table and the member editor. The tabs read the active route off the host router, because a module bundle cannot call useRoute, and landing on the page itself settles on the Overview child so the body is never empty. Only the detail endpoint carries totals, so every write re-reads the project rather than keeping what it returned. --- resources/js/init.ts | 3 + resources/js/messages/board.ts | 145 +++++ resources/js/pages/BoardPage.vue | 509 ++++++++++++++++++ resources/js/pages/ProjectDetailPage.vue | 282 ++++++++++ resources/js/pages/ProjectsIndexPage.vue | 21 +- resources/js/pages/TasksPage.vue | 53 ++ .../js/pages/project/ProjectMembersTab.vue | 203 +++++++ .../js/pages/project/ProjectOverviewTab.vue | 138 +++++ .../js/pages/project/ProjectTasksTab.vue | 51 ++ resources/js/pages/project/ProjectTimeTab.vue | 148 +++++ resources/js/registrations/board.ts | 102 ++++ resources/js/support/page.ts | 33 ++ 12 files changed, 1686 insertions(+), 2 deletions(-) create mode 100644 resources/js/messages/board.ts create mode 100644 resources/js/pages/BoardPage.vue create mode 100644 resources/js/pages/ProjectDetailPage.vue create mode 100644 resources/js/pages/TasksPage.vue create mode 100644 resources/js/pages/project/ProjectMembersTab.vue create mode 100644 resources/js/pages/project/ProjectOverviewTab.vue create mode 100644 resources/js/pages/project/ProjectTasksTab.vue create mode 100644 resources/js/pages/project/ProjectTimeTab.vue create mode 100644 resources/js/registrations/board.ts create mode 100644 resources/js/support/page.ts diff --git a/resources/js/init.ts b/resources/js/init.ts index 2c4d998..e3da209 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 { registerBoardPages } from './registrations/board' const MODULE = 'tasks-projects' @@ -20,6 +21,8 @@ window.InvoiceShelf.booting((_app, _router, extensions) => { title: 'tasks_projects.projects.title', }, }) + + registerBoardPages(extensions) }) /** diff --git a/resources/js/messages/board.ts b/resources/js/messages/board.ts new file mode 100644 index 0000000..22636c9 --- /dev/null +++ b/resources/js/messages/board.ts @@ -0,0 +1,145 @@ +/** + * Strings for the board, the task screens and the project detail. + * + * 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 + * `tasks_projects.projects` there end up in one namespace. + */ +export const boardMessages = { + en: { + tasks_projects: { + board: { + title: 'Board', + load_failed: 'Unable to load the board.', + 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', + }, + }, + tasks: { + title: 'Tasks', + all_tasks: 'All tasks', + new_task: 'New task', + edit_task: 'Edit task', + search_placeholder: 'Search by name or number', + empty_title: 'No tasks yet', + empty_description: 'Add a task to put work on the board.', + unassigned: 'Unassigned', + billable: 'Billable', + overdue: 'Overdue', + none: 'None', + columns: { + number: 'No.', + name: 'Name', + project: 'Project', + status: 'Status', + assignee: 'Assignee', + priority: 'Priority', + due_date: 'Due date', + }, + fields: { + name: 'Name', + description: 'Description', + project: 'Project', + project_placeholder: 'No project', + project_help: 'Leave empty for a task that stands on its own.', + customer: 'Customer', + customer_help: 'Taken from the project.', + status: 'Status', + assignee: 'Assignee', + assignee_placeholder: 'Nobody yet', + priority: 'Priority', + priority_placeholder: 'No priority', + due_date: 'Due date', + estimate_hours: 'Estimate (hours)', + billable: 'Billable', + rate: 'Rate override', + rate_help: 'Per hour. Leave empty to use the project or member rate.', + }, + priority: { + low: 'Low', + normal: 'Normal', + 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/pages/BoardPage.vue b/resources/js/pages/BoardPage.vue new file mode 100644 index 0000000..a9873f9 --- /dev/null +++ b/resources/js/pages/BoardPage.vue @@ -0,0 +1,509 @@ + + + diff --git a/resources/js/pages/ProjectDetailPage.vue b/resources/js/pages/ProjectDetailPage.vue new file mode 100644 index 0000000..7a83fa2 --- /dev/null +++ b/resources/js/pages/ProjectDetailPage.vue @@ -0,0 +1,282 @@ + + + diff --git a/resources/js/pages/ProjectsIndexPage.vue b/resources/js/pages/ProjectsIndexPage.vue index 12cfbb0..e33a367 100644 --- a/resources/js/pages/ProjectsIndexPage.vue +++ b/resources/js/pages/ProjectsIndexPage.vue @@ -32,7 +32,7 @@ interface TableResult { const props = defineProps<{ client: AxiosInstance notify: (type: NotifyType, message: string) => void - /** The host router, for the project detail page a later slice adds. */ + /** The host router. Links here go through ``, which uses it. */ router: Router }>() @@ -240,6 +240,15 @@ function statusLabel(status: ProjectStatus): string {