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/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/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/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.', + }, + }, + }, +} 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[] +} 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(), );