From b67a4e3405f4880f48ad56f239c0b1fa03753d1d Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 21:38:54 +0200 Subject: [PATCH 1/3] feat(ui): add the projects list, form and API client The typed client wraps the module's project, member and settings endpoints over the axios instance the host injects. The index page renders them with the host's own BasePage, BaseTable and BaseModal chrome: a debounced search and status filter, an actions dropdown for edit, archive and delete, and a create/edit modal that converts money to minor units and budgets to minutes on the way out and back. Every string the page renders comes from the module's own i18n bundle, and every colour from the host's semantic tokens. --- resources/js/api.ts | 86 +++++ resources/js/components/ProjectFormModal.vue | 284 ++++++++++++++ resources/js/pages/ProjectsIndexPage.vue | 374 +++++++++++++++++++ resources/js/support/errors.ts | 51 +++ resources/js/support/format.ts | 63 ++++ resources/js/support/i18n.ts | 18 + resources/js/types/api.ts | 30 ++ resources/js/types/member.ts | 7 + resources/js/types/project.ts | 61 +++ resources/js/types/settings.ts | 9 + 10 files changed, 983 insertions(+) create mode 100644 resources/js/api.ts create mode 100644 resources/js/components/ProjectFormModal.vue create mode 100644 resources/js/pages/ProjectsIndexPage.vue create mode 100644 resources/js/support/errors.ts create mode 100644 resources/js/support/format.ts create mode 100644 resources/js/support/i18n.ts create mode 100644 resources/js/types/api.ts create mode 100644 resources/js/types/member.ts create mode 100644 resources/js/types/project.ts create mode 100644 resources/js/types/settings.ts diff --git a/resources/js/api.ts b/resources/js/api.ts new file mode 100644 index 0000000..1a00a90 --- /dev/null +++ b/resources/js/api.ts @@ -0,0 +1,86 @@ +import type { AxiosInstance } from 'axios' +import type { Customer, Paginated, Wrapped } from '@/types/api' +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 endpoint the module owns. */ +export const TASKS_PROJECTS_API = { + projects: `${BASE}/projects`, + project: (id: number): string => `${BASE}/projects/${id}`, + archiveProject: (id: number): string => `${BASE}/projects/${id}/archive`, + unarchiveProject: (id: number): string => `${BASE}/projects/${id}/unarchive`, + members: `${BASE}/members`, + settings: `${BASE}/settings`, +} as const + +/** Host endpoints the module reads through the same client. */ +export const HOST_API = { + customers: '/api/v1/customers', +} as const + +export async function listProjects( + client: AxiosInstance, + params: ProjectListParams, +): Promise> { + const { data } = await client.get>(TASKS_PROJECTS_API.projects, { params }) + + return data +} + +export async function createProject(client: AxiosInstance, input: ProjectInput): Promise { + const { data } = await client.post>(TASKS_PROJECTS_API.projects, input) + + return data.data +} + +export async function updateProject( + client: AxiosInstance, + id: number, + input: ProjectInput, +): Promise { + const { data } = await client.put>(TASKS_PROJECTS_API.project(id), input) + + return data.data +} + +export async function archiveProject(client: AxiosInstance, id: number): Promise { + const { data } = await client.post>(TASKS_PROJECTS_API.archiveProject(id)) + + return data.data +} + +export async function unarchiveProject(client: AxiosInstance, id: number): Promise { + const { data } = await client.post>(TASKS_PROJECTS_API.unarchiveProject(id)) + + return data.data +} + +export async function deleteProject(client: AxiosInstance, id: number): Promise { + await client.delete(TASKS_PROJECTS_API.project(id)) +} + +/** The company's members, for the assignee and project member pickers. */ +export async function listMembers(client: AxiosInstance): Promise { + const { data } = await client.get>(TASKS_PROJECTS_API.members) + + return data.data +} + +export async function fetchSettings(client: AxiosInstance): Promise { + const { data } = await client.get>(TASKS_PROJECTS_API.settings) + + return data.data +} + +/** + * The company's contacts, for the project form's customer picker. This is a + * host endpoint, scoped by the same `company` header the client already sends. + */ +export async function listCustomers(client: AxiosInstance, limit = 100): Promise { + const { data } = await client.get>(HOST_API.customers, { params: { limit } }) + + return data.data +} diff --git a/resources/js/components/ProjectFormModal.vue b/resources/js/components/ProjectFormModal.vue new file mode 100644 index 0000000..bb3ab64 --- /dev/null +++ b/resources/js/components/ProjectFormModal.vue @@ -0,0 +1,284 @@ + + + diff --git a/resources/js/pages/ProjectsIndexPage.vue b/resources/js/pages/ProjectsIndexPage.vue new file mode 100644 index 0000000..12cfbb0 --- /dev/null +++ b/resources/js/pages/ProjectsIndexPage.vue @@ -0,0 +1,374 @@ + + + diff --git a/resources/js/support/errors.ts b/resources/js/support/errors.ts new file mode 100644 index 0000000..b08c8cf --- /dev/null +++ b/resources/js/support/errors.ts @@ -0,0 +1,51 @@ +/** + * Reading a Laravel error response without importing axios at runtime. + * + * The module bundle runs inside the host page and receives the host's own + * axios instance, so errors are checked structurally rather than with + * `axios.isAxiosError`. + */ + +interface ApiErrorBody { + message?: string + errors?: Record +} + +function responseBody(error: unknown): ApiErrorBody | null { + if (typeof error !== 'object' || error === null) { + return null + } + + const response = (error as { response?: { data?: unknown } }).response + + if (typeof response?.data !== 'object' || response.data === null) { + return null + } + + return response.data as ApiErrorBody +} + +/** The server's message, or the caller's fallback when there is none. */ +export function errorMessage(error: unknown, fallback: string): string { + const message = responseBody(error)?.message + + return typeof message === 'string' && message !== '' ? message : fallback +} + +/** The first validation message per field of a 422 response. */ +export function fieldErrors(error: unknown): Record { + const errors = responseBody(error)?.errors + const messages: Record = {} + + if (typeof errors !== 'object' || errors === null) { + return messages + } + + for (const [field, list] of Object.entries(errors)) { + if (Array.isArray(list) && typeof list[0] === 'string') { + messages[field] = list[0] + } + } + + return messages +} diff --git a/resources/js/support/format.ts b/resources/js/support/format.ts new file mode 100644 index 0000000..d5d4e27 --- /dev/null +++ b/resources/js/support/format.ts @@ -0,0 +1,63 @@ +/** + * Conversions between what the API stores and what a form shows. + * + * The API keeps money in integer minor units and durations in minutes; the + * form asks for major units and hours, which is what people type. + */ + +/** Minor units to the major-unit string a number input shows. */ +export function minorToMajor(amount: number | null): string { + return amount === null ? '' : String(amount / 100) +} + +/** A typed major-unit amount back to integer minor units. */ +export function majorToMinor(value: string): number | null { + const amount = Number(value) + + return value.trim() === '' || Number.isNaN(amount) ? null : Math.round(amount * 100) +} + +export function minutesToHours(minutes: number | null): string { + return minutes === null ? '' : String(minutes / 60) +} + +export function hoursToMinutes(value: string): number | null { + const hours = Number(value) + + return value.trim() === '' || Number.isNaN(hours) ? null : Math.round(hours * 60) +} + +/** + * A `Y-m-d` date in the viewer's locale. The date is a calendar date rather + * than an instant, so it is read and printed in UTC and never shifts a day. + */ +export function formatDate(value: string | null): string { + if (!value) { + return '' + } + + const [year, month, day] = value.slice(0, 10).split('-').map(Number) + + if (!year || !month || !day) { + return value + } + + return new Date(Date.UTC(year, month - 1, day)).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) +} + +/** What a date picker hands back, normalised to the `Y-m-d` the API takes. */ +export function toDateString(value: string | Date): string { + if (typeof value === 'string') { + return value.slice(0, 10) + } + + const month = String(value.getMonth() + 1).padStart(2, '0') + const day = String(value.getDate()).padStart(2, '0') + + return `${value.getFullYear()}-${month}-${day}` +} diff --git a/resources/js/support/i18n.ts b/resources/js/support/i18n.ts new file mode 100644 index 0000000..2a1f01b --- /dev/null +++ b/resources/js/support/i18n.ts @@ -0,0 +1,18 @@ +import { getCurrentInstance } from 'vue' + +export type Translate = (key: string, named?: Record) => string + +/** + * The host's `$t` for use outside a template. + * + * A module bundle cannot call `useI18n()`, because it would look the composer + * up through its own injection symbols, so the translator is read off the host + * app's global properties instead. Call this during `setup`. + */ +export function useTranslate(): Translate { + const translate = getCurrentInstance()?.appContext.config.globalProperties.$t as + | Translate + | undefined + + return translate ?? ((key: string): string => key) +} diff --git a/resources/js/types/api.ts b/resources/js/types/api.ts new file mode 100644 index 0000000..11083c7 --- /dev/null +++ b/resources/js/types/api.ts @@ -0,0 +1,30 @@ +/** Shapes the host and the module share on every list endpoint. */ + +export interface PaginationMeta { + current_page: number + last_page: number + per_page: number + total: number +} + +/** A Laravel resource collection over a paginator. */ +export interface Paginated { + data: T[] + meta: PaginationMeta +} + +/** A single Laravel resource, which the host always wraps in `data`. */ +export interface Wrapped { + data: T +} + +/** + * A host contact, as `/api/v1/customers` renders it. Only the fields the + * project form needs are typed; the endpoint returns many more. + */ +export interface Customer { + id: number + name: string | null + display_name?: string | null + currency_id: number | null +} diff --git a/resources/js/types/member.ts b/resources/js/types/member.ts new file mode 100644 index 0000000..1d2067b --- /dev/null +++ b/resources/js/types/member.ts @@ -0,0 +1,7 @@ +/** A user of the active company, as the module's members endpoint renders it. */ +export interface CompanyMember { + id: number + name: string + email: string + avatar: string | null +} diff --git a/resources/js/types/project.ts b/resources/js/types/project.ts new file mode 100644 index 0000000..8253c90 --- /dev/null +++ b/resources/js/types/project.ts @@ -0,0 +1,61 @@ +/** The project as `ProjectResource` renders it. Money is integer minor units. */ + +export type ProjectStatus = 'ACTIVE' | 'ARCHIVED' + +export interface ProjectTaskTotals { + total: number + open: number + closed: number +} + +export interface ProjectTotals { + tasks: ProjectTaskTotals + logged_minutes: number + billable_minutes: number + billable_amount: number + unbilled_amount: number + currency_id: number | null +} + +export interface Project { + id: number + company_id: number + customer_id: number | null + name: string + identifier: string | null + description: string | null + colour: string | null + status: ProjectStatus + currency_id: number | null + /** Minor units per hour. */ + default_rate: number | null + budget_minutes: number | null + due_date: string | null + creator_id: number | null + is_internal: boolean + created_at: string | null + updated_at: string | null + /** Only the detail endpoint carries these. */ + totals?: ProjectTotals +} + +/** What the create and update endpoints accept. */ +export interface ProjectInput { + name: string + customer_id: number | null + identifier: string | null + description: string | null + colour: string | null + currency_id?: number | null + default_rate: number | null + budget_minutes: number | null + due_date: string | null +} + +export interface ProjectListParams { + page?: number + limit?: number + /** Omitted when the filter is "all". */ + status?: ProjectStatus + search?: string +} diff --git a/resources/js/types/settings.ts b/resources/js/types/settings.ts new file mode 100644 index 0000000..1e0b160 --- /dev/null +++ b/resources/js/types/settings.ts @@ -0,0 +1,9 @@ +/** 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 + week_start: number + members_see_all_time: boolean + rounding_increments: number[] +} From f7a9667f2ae17435e39c2e0e8689249915d739a3 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 21:39:00 +0200 Subject: [PATCH 2/3] feat(ui): register the projects page through the host page contract `registerPage` mounts the index at /admin/modules/tasks-projects, where the sidebar entry already points, behind the module's own view-project ability. The page receives the host client, notifier and router as props, because a module bundle runs on the host's Vue instance but not on its injections. --- dist/init.js | 748 ++++++++++++++++++++++++++++++++++++++- dist/style.css | 2 +- resources/js/init.ts | 44 ++- resources/js/messages.ts | 73 ++++ 4 files changed, 859 insertions(+), 8 deletions(-) create mode 100644 resources/js/messages.ts diff --git a/dist/init.js b/dist/init.js index 768ac37..e7630bf 100644 --- a/dist/init.js +++ b/dist/init.js @@ -1,5 +1,749 @@ -//#region resources/js/init.ts +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; +//#region resources/js/messages.ts +var E = { en: { tasks_projects: { + general: { + home: "Home", + filter: "Filter", + search: "Search", + actions: "Actions", + edit: "Edit", + delete: "Delete", + cancel: "Cancel", + save: "Save", + update: "Update" + }, + projects: { + title: "Projects", + new_project: "New project", + edit_project: "Edit project", + internal: "Internal", + archive: "Archive", + unarchive: "Restore", + search_placeholder: "Search by name or identifier", + empty_title: "No projects yet", + empty_description: "Create a project to group its tasks, time and billing.", + status: { + active: "Active", + archived: "Archived", + all: "All" + }, + columns: { + name: "Name", + status: "Status", + customer: "Customer", + default_rate: "Rate / hour", + due_date: "Due date" + }, + fields: { + name: "Name", + identifier: "Identifier", + identifier_help: "A short code, used as the task number prefix.", + customer: "Customer", + customer_help: "Leave empty for an internal project.", + customer_placeholder: "No customer", + due_date: "Due date", + default_rate: "Default rate", + default_rate_help: "Per hour, in the customer currency.", + budget_hours: "Budget (hours)", + colour: "Colour", + colour_none: "None", + description: "Description" + }, + created: "{name} was created.", + updated: "{name} was updated.", + archived: "{name} was archived.", + unarchived: "{name} was restored.", + deleted: "{name} was deleted.", + delete_confirm: "Delete {name}? Its tasks and time entries go with it.", + name_required: "Enter a project name.", + load_failed: "Unable to load the projects.", + save_failed: "Unable to save the project.", + 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 }); + return n; +} +async function j(e, t) { + let { data: n } = await e.post(O.projects, t); + return n.data; +} +async function M(e, t, n) { + let { data: r } = await e.put(O.project(t), n); + return r.data; +} +async function N(e, t) { + let { data: n } = await e.post(O.archiveProject(t)); + return n.data; +} +async function P(e, t) { + let { data: n } = await e.post(O.unarchiveProject(t)); + return n.data; +} +async function F(e, t) { + await e.delete(O.project(t)); +} +async function I(e, t = 100) { + let { data: n } = await e.get(k.customers, { params: { limit: t } }); + return n.data; +} +//#endregion +//#region resources/js/support/errors.ts +function L(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; + return typeof n == "string" && n !== "" ? n : t; +} +function z(e) { + let t = L(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) { + return e === null ? "" : String(e / 100); +} +function V(e) { + let t = Number(e); + return e.trim() === "" || Number.isNaN(t) ? null : Math.round(t * 100); +} +function H(e) { + return e === null ? "" : String(e / 60); +} +function U(e) { + let t = Number(e); + return e.trim() === "" || Number.isNaN(t) ? null : Math.round(t * 60); +} +function W(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, { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC" + }); +} +function G(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); +} +//#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({ + __name: "ProjectFormModal", + props: { + show: { type: Boolean }, + client: { type: [Function, Object] }, + notify: { type: Function }, + project: {} + }, + emits: ["close", "saved"], + setup(r, { emit: c }) { + let l = r, u = c, p = [ + "#2563eb", + "#0891b2", + "#059669", + "#ca8a04", + "#ea580c", + "#dc2626", + "#7c3aed", + "#64748b" + ], x = K(), w = h({ + name: "", + identifier: "", + description: "", + colour: "", + 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()); + }, { immediate: !0 }); + function F() { + 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); + } + function L(e) { + return e === null ? null : D.value.find((t) => t.id === e) ?? null; + } + function W(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) => ({ + id: e.id, + label: W(e) + })), O.value = !0, E.value = L(l.project?.customer_id ?? null); + } catch (e) { + l.notify("error", R(e, x("tasks_projects.projects.customers_failed"))); + } + } + 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 + }; + } + function ne(e) { + w.dueDate = e ? G(e) : ""; + } + async function re() { + if (!A.value) { + if (w.name.trim() === "") { + k.value = { name: x("tasks_projects.projects.name_required") }; + return; + } + A.value = !0, k.value = {}; + try { + let e = l.project, t = e ? await M(l.client, e.id, Z()) : await j(l.client, Z()); + u("saved", t); + } catch (e) { + k.value = z(e), l.notify("error", R(e, x("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") + }, { + header: C(() => [a("div", q, [a("span", null, y(P.value), 1), s(l, { + name: "XMarkIcon", + class: "h-6 w-6 cursor-pointer text-subtle hover:text-body", + onClick: c[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, + required: "" + }, { + default: C(() => [s(h, { + modelValue: w.name, + "onUpdate:modelValue": c[1] ||= (e) => w.name = e, + invalid: !!k.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") + }, { + default: C(() => [s(h, { + modelValue: w.identifier, + "onUpdate:modelValue": c[2] ||= (e) => w.identifier = e, + invalid: !!k.value.identifier, + type: "text", + maxlength: "32" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, [ + "label", + "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") + }, { + 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"), + "label-key": "label" + }, null, 8, [ + "modelValue", + "options", + "placeholder" + ])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ]), + s(g, { + label: b(x)("tasks_projects.projects.fields.due_date"), + error: k.value.due_date + }, { + default: C(() => [s(O, { + "model-value": w.dueDate, + "onUpdate:modelValue": ne + }, 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") + }, { + default: C(() => [s(h, { + modelValue: w.defaultRate, + "onUpdate:modelValue": c[4] ||= (e) => w.defaultRate = e, + invalid: !!k.value.default_rate, + type: "number", + step: "0.01", + min: "0" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, [ + "label", + "error", + "help-text" + ]), + s(g, { + label: b(x)("tasks_projects.projects.fields.budget_hours"), + error: k.value.budget_minutes + }, { + default: C(() => [s(h, { + modelValue: w.budgetHours, + "onUpdate:modelValue": c[5] ||= (e) => w.budgetHours = e, + invalid: !!k.value.budget_minutes, + type: "number", + step: "0.25", + min: "0" + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"]) + ]), + _: 1 + }), + s(g, { + label: b(x)("tasks_projects.projects.fields.colour"), + error: k.value.colour + }, { + default: C(() => [a("div", Y, [(m(), i(e, null, _(p, (e) => a("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 }), + "aria-label": e, + onClick: (t) => w.colour = w.colour === e ? "" : e + }, null, 14, X)), 64)), a("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)])]), + _: 1 + }, 8, ["label", "error"]), + s(g, { + label: b(x)("tasks_projects.projects.fields.description"), + error: k.value.description + }, { + default: C(() => [s(M, { + modelValue: w.description, + "onUpdate:modelValue": c[7] ||= (e) => w.description = e, + row: 3, + invalid: !!k.value.description + }, null, 8, ["modelValue", "invalid"])]), + _: 1 + }, 8, ["label", "error"]) + ]), a("div", ee, [s(F, { + type: "button", + variant: "primary-outline", + onClick: c[8] ||= (e) => u("close") + }, { + default: C(() => [o(y(b(x)("tasks_projects.general.cancel")), 1)]), + _: 1 + }), s(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)]), + _: 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 = { + key: 0, + class: "block text-xs font-normal text-muted" +}, ae = { key: 0 }, oe = { + key: 1, + class: "text-subtle" +}, se = { + key: 1, + class: "text-subtle" +}, ce = { key: 0 }, le = { + key: 1, + class: "text-subtle" +}, Q = 10, ue = 350, de = /* @__PURE__ */ c({ + __name: "ProjectsIndexPage", + props: { + client: { type: [Function, Object] }, + notify: { type: Function }, + 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({ + search: "", + status: "ACTIVE" + }), M = t(() => [ + { + id: "ACTIVE", + label: l("tasks_projects.projects.status.active") + }, + { + id: "ARCHIVED", + label: l("tasks_projects.projects.status.archived") + }, + { + id: "ALL", + label: l("tasks_projects.projects.status.all") + } + ]), I = t({ + get: () => M.value.find((e) => e.id === j.status) ?? M.value[0], + set: (e) => { + j.status = e.id; + } + }), L = t(() => [ + { + key: "name", + label: l("tasks_projects.projects.columns.name"), + sortable: !1, + thClass: "extra", + tdClass: "font-medium text-heading" + }, + { + key: "status", + label: l("tasks_projects.projects.columns.status"), + sortable: !1 + }, + { + key: "customer", + label: l("tasks_projects.projects.columns.customer"), + sortable: !1 + }, + { + key: "default_rate", + label: l("tasks_projects.projects.columns.default_rate"), + sortable: !1 + }, + { + key: "due_date", + label: l("tasks_projects.projects.columns.due_date"), + sortable: !1 + }, + { + key: "actions", + label: l("tasks_projects.general.actions"), + 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 = { + page: e, + limit: Q + }; + j.status !== "ALL" && (t.status = j.status), j.search.trim() !== "" && (t.search = j.search.trim()), T.value = !0; + try { + let e = await A(c.client, t); + return E.value = e.meta.total, { + data: e.data, + pagination: { + totalPages: e.meta.last_page, + currentPage: e.meta.current_page, + totalCount: e.meta.total, + limit: e.meta.per_page + } + }; + } catch (e) { + return c.notify("error", R(e, l("tasks_projects.projects.load_failed"))), { + data: [], + pagination: { + totalPages: 1, + currentPage: 1, + totalCount: 0, + limit: Q + } + }; + } finally { + T.value = !1; + } + } + function U(e = !1) { + u.value?.refresh(e); + } + function G() { + _.value && q(), _.value = !_.value; + } + function q() { + j.search = "", j.status = "ACTIVE"; + } + function J() { + O.value = null, D.value = !0; + } + function Y(e) { + O.value = e, D.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(); + } + async function ee(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); + } catch (e) { + c.notify("error", R(e, l("tasks_projects.projects.save_failed"))); + } finally { + k.value = null; + } + } + async function de(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); + } catch (e) { + c.notify("error", R(e, l("tasks_projects.projects.delete_failed"))); + } finally { + k.value = null; + } + } + } + function $(e) { + return e === "ACTIVE" ? "bg-primary-50! text-primary-500!" : "bg-surface-tertiary! text-muted!"; + } + function fe(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, { + variant: "primary-outline", + onClick: G + }, { + right: C((e) => [_.value ? (m(), n(g, { + key: 1, + name: "XMarkIcon", + class: d(e.class) + }, null, 8, ["class"])) : (m(), n(g, { + key: 0, + name: "FunnelIcon", + class: d(e.class) + }, null, 8, ["class"]))]), + default: C(() => [o(y(b(l)("tasks_projects.general.filter")) + " ", 1)]), + _: 1 + }), s(S, { + variant: "primary", + onClick: J + }, { + left: C((e) => [s(g, { + name: "PlusIcon", + class: d(e.class) + }, null, 8, ["class"])]), + default: C(() => [o(" " + y(b(l)("tasks_projects.projects.new_project")), 1)]), + _: 1 + })])]), + default: C(() => [s(h, null, { + default: C(() => [s(p, { + title: b(l)("tasks_projects.general.home"), + to: "/admin/dashboard" + }, null, 8, ["title"]), s(p, { + title: b(l)("tasks_projects.projects.title"), + to: "#", + active: "" + }, null, 8, ["title"])]), + _: 1 + })]), + _: 1 + }, 8, ["title"]), + s(P, { + show: _.value, + class: "mt-3", + onClear: q + }, { + default: C(() => [s(A, { + label: b(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, + type: "text", + name: "search", + autocomplete: "off", + placeholder: b(l)("tasks_projects.projects.search_placeholder") + }, null, 8, ["modelValue", "placeholder"])]), + _: 1 + }, 8, ["label"]), s(A, { + label: b(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, + "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") + }, { + actions: C(() => [s(S, { + variant: "primary", + onClick: J + }, { + left: C((e) => [s(g, { + name: "PlusIcon", + class: d(e.class) + }, null, 8, ["class"])]), + default: C(() => [o(" " + y(b(l)("tasks_projects.projects.new_project")), 1)]), + _: 1 + })]), + default: C(() => [s(g, { + 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, { + ref_key: "tableRef", + ref: u, + data: H, + columns: L.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)]), + _: 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, { + 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, { + name: "EllipsisHorizontalIcon", + class: "h-5 text-muted" + })]), + default: C(() => [ + s(V, { onClick: (t) => Y(e.data) }, { + default: C(() => [s(g, { + name: "PencilIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), o(" " + y(b(l)("tasks_projects.general.edit")), 1)]), + _: 1 + }, 8, ["onClick"]), + s(V, { onClick: (t) => ee(e.data) }, { + default: C(() => [s(g, { + 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)]), + _: 2 + }, 1032, ["onClick"]), + s(V, { onClick: (t) => de(e.data) }, { + default: C(() => [s(g, { + name: "TrashIcon", + class: "mr-3 h-5 w-5 text-subtle group-hover:text-muted" + }), o(" " + y(b(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, + client: e.client, + notify: e.notify, + project: O.value, + onClose: c[2] ||= (e) => D.value = !1, + onSaved: X + }, null, 8, [ + "show", + "client", + "notify", + "project" + ]) + ]), + _: 1 + }); + }; + } +}), $ = "tasks-projects"; window.InvoiceShelf.booting((e, t, n) => { - n.addMessages({ en: { tasks_projects: { title: "Projects" } } }); + n.addMessages(E), n.registerPage({ + id: "projects", + module: $, + path: "", + component: fe(n, de), + meta: { + ability: `${$}:view-project`, + title: "tasks_projects.projects.title" + } + }); }); +function fe(e, t) { + return c({ setup: (n, { attrs: r }) => () => u(t, { + ...r, + client: e.client, + notify: (t, n) => { + e.notify(t, n); + }, + router: e.router + }) }); +} //#endregion diff --git a/dist/style.css b/dist/style.css index 835e6e5..d1084a1 100644 --- a/dist/style.css +++ b/dist/style.css @@ -1,3 +1,3 @@ /*! tailwindcss v4.3.3 | MIT License | https://tailwindcss.com */ -@layer utilities; +@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} /*$vite$:1*/ \ No newline at end of file diff --git a/resources/js/init.ts b/resources/js/init.ts index 46c7742..2c4d998 100644 --- a/resources/js/init.ts +++ b/resources/js/init.ts @@ -1,11 +1,45 @@ +import { defineComponent, h } from 'vue' +import type { Component } from 'vue' +import type { InvoiceShelfExtensionApi } from '@invoiceshelf/modules/frontend' import '../css/module.css' +import { messages } from './messages' +import ProjectsIndexPage from './pages/ProjectsIndexPage.vue' + +const MODULE = 'tasks-projects' window.InvoiceShelf.booting((_app, _router, extensions) => { - extensions.addMessages({ - en: { - tasks_projects: { - title: 'Projects', - }, + extensions.addMessages(messages) + + extensions.registerPage({ + id: 'projects', + module: MODULE, + path: '', + component: injected(extensions, ProjectsIndexPage), + meta: { + ability: `${MODULE}:view-project`, + title: 'tasks_projects.projects.title', }, }) }) + +/** + * Hand a page the host services it cannot reach on its own. + * + * 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. 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: 'success' | 'error' | 'warning' | 'info', message: string): void => { + extensions.notify(type, message) + }, + router: extensions.router, + }), + }) +} diff --git a/resources/js/messages.ts b/resources/js/messages.ts new file mode 100644 index 0000000..0116430 --- /dev/null +++ b/resources/js/messages.ts @@ -0,0 +1,73 @@ +/** + * Every string the module renders, in one bundle. + * + * The host merges these into its own i18n catalogue, so templates reach them + * with `$t('tasks_projects....')` and a later locale only has to add a sibling + * key here. + */ +export const messages = { + en: { + tasks_projects: { + general: { + home: 'Home', + filter: 'Filter', + search: 'Search', + actions: 'Actions', + edit: 'Edit', + delete: 'Delete', + cancel: 'Cancel', + save: 'Save', + update: 'Update', + }, + projects: { + title: 'Projects', + new_project: 'New project', + edit_project: 'Edit project', + internal: 'Internal', + archive: 'Archive', + unarchive: 'Restore', + search_placeholder: 'Search by name or identifier', + empty_title: 'No projects yet', + empty_description: 'Create a project to group its tasks, time and billing.', + status: { + active: 'Active', + archived: 'Archived', + all: 'All', + }, + columns: { + name: 'Name', + status: 'Status', + customer: 'Customer', + default_rate: 'Rate / hour', + due_date: 'Due date', + }, + fields: { + name: 'Name', + identifier: 'Identifier', + identifier_help: 'A short code, used as the task number prefix.', + customer: 'Customer', + customer_help: 'Leave empty for an internal project.', + customer_placeholder: 'No customer', + due_date: 'Due date', + default_rate: 'Default rate', + default_rate_help: 'Per hour, in the customer currency.', + budget_hours: 'Budget (hours)', + colour: 'Colour', + colour_none: 'None', + description: 'Description', + }, + created: '{name} was created.', + updated: '{name} was updated.', + archived: '{name} was archived.', + unarchived: '{name} was restored.', + deleted: '{name} was deleted.', + delete_confirm: 'Delete {name}? Its tasks and time entries go with it.', + name_required: 'Enter a project name.', + load_failed: 'Unable to load the projects.', + save_failed: 'Unable to save the project.', + delete_failed: 'Unable to delete the project.', + customers_failed: 'Unable to load the customers.', + }, + }, + }, +} From dc25fd995b6e6b31a763096f1cee2d343919e13b Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Mon, 14 Sep 2026 21:39:07 +0200 Subject: [PATCH 3/3] feat: a project inherits the currency of its customer A project carries one currency and its rates are compared in it, so filing one under a customer without naming a currency now reads the customer's own through the host company reader, and changing the customer moves the project with it. A currency the caller names always wins, and an explicit null still clears it. --- app/Application/ProjectService.php | 46 ++++++++++++++- tests/Support/MemoryCompanyDataReader.php | 28 +++++++-- tests/Unit/ProjectServiceTest.php | 72 ++++++++++++++++++++++- tests/Unit/TaskServiceTest.php | 2 +- tests/Unit/TimeEntryServiceTest.php | 2 +- tests/Unit/TimerServiceTest.php | 2 +- 6 files changed, 141 insertions(+), 11 deletions(-) diff --git a/app/Application/ProjectService.php b/app/Application/ProjectService.php index b15de48..d48f0ce 100644 --- a/app/Application/ProjectService.php +++ b/app/Application/ProjectService.php @@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Support\Facades\DB; +use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader; use Modules\TasksProjects\Application\Exceptions\ProjectInUse; use Modules\TasksProjects\Models\Project; use Modules\TasksProjects\Models\ProjectMember; @@ -23,6 +24,8 @@ final class ProjectService 'currency_id', 'default_rate', 'budget_minutes', 'due_date', 'creator_id', ]; + public function __construct(private readonly CompanyDataReader $companyData) {} + /** * @param array{status?: string, customer_id?: int, user_id?: int, search?: string} $filters * @return Collection @@ -68,7 +71,12 @@ public function findForCompany(int $companyId, int $id): Project return $project; } - /** @param array $attributes */ + /** + * A project's currency follows the customer it was filed under, unless the + * caller named one itself. + * + * @param array $attributes + */ public function create(int $companyId, array $attributes): Project { $values = ['company_id' => $companyId, 'status' => Project::STATUS_ACTIVE]; @@ -79,12 +87,21 @@ public function create(int $companyId, array $attributes): Project } } + if (! array_key_exists('currency_id', $attributes)) { + $currencyId = $this->customerCurrency($companyId, $values['customer_id'] ?? null); + + if ($currencyId !== null) { + $values['currency_id'] = $currencyId; + } + } + return Project::query()->create($values); } /** * A project's customer is denormalised onto its tasks, so changing it - * rewrites the tasks that follow the project. + * rewrites the tasks that follow the project, and moves the project to + * that customer's currency unless the caller named one itself. * * @param array $attributes */ @@ -101,6 +118,14 @@ public function update(int $companyId, int $id, array $attributes): Project } } + if (! array_key_exists('currency_id', $attributes) && array_key_exists('customer_id', $attributes)) { + $currencyId = $this->customerCurrency($companyId, $project->customer_id); + + if ($currencyId !== null) { + $project->currency_id = $currencyId; + } + } + $project->save(); if ($customerChanged) { @@ -207,6 +232,23 @@ public function totals(Project $project): array ]; } + /** + * The currency of one customer, read through the host contract. + * + * An internal project has no customer and so no currency to inherit, and a + * customer without one leaves the project's currency alone. + */ + private function customerCurrency(int $companyId, mixed $customerId): ?int + { + if ($customerId === null) { + return null; + } + + $currencyId = $this->companyData->findCustomer($companyId, (int) $customerId)['currency_id'] ?? null; + + return $currencyId === null ? null : (int) $currencyId; + } + private function setStatus(int $companyId, int $id, string $status): Project { $project = $this->findForCompany($companyId, $id); diff --git a/tests/Support/MemoryCompanyDataReader.php b/tests/Support/MemoryCompanyDataReader.php index a1d6447..b518a79 100644 --- a/tests/Support/MemoryCompanyDataReader.php +++ b/tests/Support/MemoryCompanyDataReader.php @@ -9,10 +9,11 @@ /** * An in-memory stand-in for the host company reader. * - * Only the two surfaces this module actually uses carry state: the invoice ids - * that still exist, which decide whether a stamped entry counts as billed, and - * the company member list, which supplies names for the member grouping. The - * rest satisfy the contract and return nothing. + * Only the surfaces this module actually uses carry state: the invoice ids + * that still exist, which decide whether a stamped entry counts as billed, the + * company member list, which supplies names for the member grouping, and the + * customers, which lend a new project its currency. The rest satisfy the + * contract and return nothing. */ final class MemoryCompanyDataReader implements CompanyDataReader { @@ -22,6 +23,9 @@ final class MemoryCompanyDataReader implements CompanyDataReader /** @var array> */ public array $members = []; + /** @var array>> customers, keyed by company then id */ + public array $customers = []; + /** @var list}> */ public array $invoiceLookups = []; @@ -44,6 +48,20 @@ public function withMember(int $companyId, int $userId, string $name): self return $this; } + public function withCustomer(int $companyId, int $customerId, ?int $currencyId = null): self + { + $this->customers[$companyId][$customerId] = [ + 'id' => $customerId, + 'name' => 'Customer '.$customerId, + 'currency_id' => $currencyId, + 'currency' => $currencyId === null + ? null + : ['id' => $currencyId, 'code' => 'EUR', 'symbol' => 'E', 'precision' => 2], + ]; + + return $this; + } + /** @return array */ public function companyStats(int $companyId, string $startDate, string $endDate): array { @@ -53,7 +71,7 @@ public function companyStats(int $companyId, string $startDate, string $endDate) /** @return array|null */ public function findCustomer(int $companyId, int $customerId): ?array { - return null; + return $this->customers[$companyId][$customerId] ?? null; } /** @return array */ diff --git a/tests/Unit/ProjectServiceTest.php b/tests/Unit/ProjectServiceTest.php index 7fbf320..3ce8750 100644 --- a/tests/Unit/ProjectServiceTest.php +++ b/tests/Unit/ProjectServiceTest.php @@ -26,7 +26,7 @@ protected function setUp(): void { parent::setUp(); - $this->projects = new ProjectService; + $this->projects = new ProjectService($this->companyData); $this->members = new ProjectMemberService($this->projects); } @@ -85,6 +85,76 @@ public function test_changing_the_customer_rewrites_the_tasks_that_follow_the_pr self::assertSame(43, $task->fresh()->customer_id); } + public function test_a_new_project_inherits_the_currency_of_its_customer(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + + self::assertSame(3, $project->currency_id); + } + + public function test_a_named_currency_survives_the_customer_it_was_filed_under(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => 42, + 'currency_id' => 4, + ]); + + self::assertSame(4, $project->currency_id); + } + + public function test_an_internal_project_and_a_customer_without_a_currency_stay_currencyless(): void + { + $this->companyData->withCustomer(self::COMPANY, 43, null); + + $internal = $this->projects->create(self::COMPANY, ['name' => 'Internal tooling']); + $unpriced = $this->projects->create(self::COMPANY, ['name' => 'Favour', 'customer_id' => 43]); + + self::assertNull($internal->currency_id); + self::assertNull($unpriced->currency_id); + } + + public function test_changing_the_customer_moves_the_project_to_that_customers_currency(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3)->withCustomer(self::COMPANY, 43, 4); + + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + $moved = $this->projects->update(self::COMPANY, (int) $project->id, ['customer_id' => 43]); + + self::assertSame(4, $moved->currency_id); + } + + public function test_an_update_that_leaves_the_customer_alone_leaves_the_currency_alone(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, [ + 'name' => 'Website', + 'customer_id' => 42, + 'currency_id' => 4, + ]); + $renamed = $this->projects->update(self::COMPANY, (int) $project->id, ['name' => 'Website 2']); + + self::assertSame(4, $renamed->currency_id); + } + + public function test_an_explicit_null_currency_clears_it(): void + { + $this->companyData->withCustomer(self::COMPANY, 42, 3); + + $project = $this->projects->create(self::COMPANY, ['name' => 'Website', 'customer_id' => 42]); + $cleared = $this->projects->update(self::COMPANY, (int) $project->id, [ + 'customer_id' => 42, + 'currency_id' => null, + ]); + + self::assertNull($cleared->currency_id); + } + public function test_a_member_is_attached_with_a_rate_and_reattaching_updates_it(): void { $project = $this->projects->create(self::COMPANY, ['name' => 'Website']); diff --git a/tests/Unit/TaskServiceTest.php b/tests/Unit/TaskServiceTest.php index 5fa8f58..a6363b3 100644 --- a/tests/Unit/TaskServiceTest.php +++ b/tests/Unit/TaskServiceTest.php @@ -33,7 +33,7 @@ protected function setUp(): void new TaskNumberSequence, new BoardOrderingService, $this->statuses, - new ProjectService, + new ProjectService($this->companyData), ); } diff --git a/tests/Unit/TimeEntryServiceTest.php b/tests/Unit/TimeEntryServiceTest.php index 7b78e6c..24058a0 100644 --- a/tests/Unit/TimeEntryServiceTest.php +++ b/tests/Unit/TimeEntryServiceTest.php @@ -33,7 +33,7 @@ protected function setUp(): void $this->entries = new TimeEntryService( new RateResolver, $this->moduleSettings(), - new TaskService(new TaskNumberSequence, new BoardOrderingService, new TaskStatusService, new ProjectService), + new TaskService(new TaskNumberSequence, new BoardOrderingService, new TaskStatusService, new ProjectService($this->companyData)), ); } diff --git a/tests/Unit/TimerServiceTest.php b/tests/Unit/TimerServiceTest.php index 143c1f4..9449527 100644 --- a/tests/Unit/TimerServiceTest.php +++ b/tests/Unit/TimerServiceTest.php @@ -32,7 +32,7 @@ protected function setUp(): void parent::setUp(); $this->timer = new TimerService( - new TaskService(new TaskNumberSequence, new BoardOrderingService, new TaskStatusService, new ProjectService), + new TaskService(new TaskNumberSequence, new BoardOrderingService, new TaskStatusService, new ProjectService($this->companyData)), new RateResolver, $this->moduleSettings(), );